refactor(core): Use DTOs for source control push/pull requests (#12470)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-01-09 15:31:07 +01:00
committed by GitHub
parent ecff3b732a
commit 1d86c4fdd2
29 changed files with 305 additions and 234 deletions

View File

@@ -36,6 +36,9 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
export { PullWorkFolderRequestDto } from './source-control/pull-work-folder-request.dto';
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
export { VariableListRequestDto } from './variables/variables-list-request.dto';
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';

View File

@@ -0,0 +1,38 @@
import { PullWorkFolderRequestDto } from '../pull-work-folder-request.dto';
describe('PullWorkFolderRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with force',
request: { force: true },
},
{
name: 'without force',
request: {},
},
])('should validate $name', ({ request }) => {
const result = PullWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid force type',
request: {
force: 'true', // Should be boolean
},
expectedErrorPath: ['force'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = PullWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,112 @@
import { PushWorkFolderRequestDto } from '../push-work-folder-request.dto';
describe('PushWorkFolderRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid push request with all fields',
request: {
force: true,
fileNames: [
{
file: 'file1.json',
id: '1',
name: 'File 1',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-01T12:00:00Z',
pushed: true,
},
],
message: 'Initial commit',
},
},
{
name: 'push request with only required fields',
request: {
fileNames: [
{
file: 'file2.json',
id: '2',
name: 'File 2',
type: 'credential',
status: 'new',
location: 'remote',
conflict: true,
updatedAt: '2023-10-02T12:00:00Z',
},
],
},
},
])('should validate $name', ({ request }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing required fileNames field',
request: {
force: true,
message: 'Initial commit',
},
expectedErrorPath: ['fileNames'],
},
{
name: 'invalid fileNames type',
request: {
fileNames: 'not-an-array', // Should be an array
},
expectedErrorPath: ['fileNames'],
},
{
name: 'invalid fileNames array element',
request: {
fileNames: [
{
file: 'file4.json',
id: '4',
name: 'File 4',
type: 'invalid-type', // Invalid type
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-04T12:00:00Z',
},
],
},
expectedErrorPath: ['fileNames', 0, 'type'],
},
{
name: 'invalid force type',
request: {
force: 'true', // Should be boolean
fileNames: [
{
file: 'file5.json',
id: '5',
name: 'File 5',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-05T12:00:00Z',
},
],
},
expectedErrorPath: ['force'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class PullWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
}) {}

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema';
export class PushWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
commitMessage: z.string().optional(),
fileNames: z.array(SourceControlledFileSchema),
}) {}

View File

@@ -10,9 +10,17 @@ export type { SendWorkerStatusMessage } from './push/worker';
export type { BannerName } from './schemas/bannerName.schema';
export { passwordSchema } from './schemas/password.schema';
export {
ProjectType,
ProjectIcon,
ProjectRole,
ProjectRelation,
} from './schemas/project.schema';
export {
type SourceControlledFile,
SOURCE_CONTROL_FILE_LOCATION,
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
} from './schemas/source-controlled-file.schema';

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
const FileTypeSchema = z.enum(['credential', 'workflow', 'tags', 'variables', 'file']);
export const SOURCE_CONTROL_FILE_TYPE = FileTypeSchema.Values;
const FileStatusSchema = z.enum([
'new',
'modified',
'deleted',
'created',
'renamed',
'conflicted',
'ignored',
'staged',
'unknown',
]);
export const SOURCE_CONTROL_FILE_STATUS = FileStatusSchema.Values;
const FileLocationSchema = z.enum(['local', 'remote']);
export const SOURCE_CONTROL_FILE_LOCATION = FileLocationSchema.Values;
export const SourceControlledFileSchema = z.object({
file: z.string(),
id: z.string(),
name: z.string(),
type: FileTypeSchema,
status: FileStatusSchema,
location: FileLocationSchema,
conflict: z.boolean(),
updatedAt: z.string(),
pushed: z.boolean().optional(),
});
export type SourceControlledFile = z.infer<typeof SourceControlledFileSchema>;

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import mock from 'jest-mock-extended/lib/Mock';
import { Cipher, type InstanceSettings } from 'n8n-core';
@@ -9,7 +10,6 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
import { mockInstance } from '@test/mocking';
import { SourceControlExportService } from '../source-control-export.service.ee';
import type { SourceControlledFile } from '../types/source-controlled-file';
// https://github.com/jestjs/jest/issues/4715
function deepSpyOn<O extends object, M extends keyof O>(object: O, methodName: M) {

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { constants as fsConstants, accessSync } from 'fs';
import { InstanceSettings } from 'n8n-core';
@@ -17,7 +18,6 @@ import {
} from '@/environments.ee/source-control/source-control-helper.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import type { SourceControlPreferences } from '@/environments.ee/source-control/types/source-control-preferences';
import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file';
import { License } from '@/license';
import { mockInstance } from '@test/mocking';

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
import { rmSync } from 'fs';
import { Credentials, InstanceSettings, Logger } from 'n8n-core';
@@ -29,7 +30,6 @@ import type { ExportResult } from './types/export-result';
import type { ExportableCredential } from './types/exportable-credential';
import type { ExportableWorkflow } from './types/exportable-workflow';
import type { ResourceOwner } from './types/resource-owner';
import type { SourceControlledFile } from './types/source-controlled-file';
import { VariablesService } from '../variables/variables.service.ee';
@Service()

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { generateKeyPairSync } from 'crypto';
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
@@ -16,7 +17,6 @@ import {
} from './constants';
import type { KeyPair } from './types/key-pair';
import type { KeyPairType } from './types/key-pair-type';
import type { SourceControlledFile } from './types/source-controlled-file';
export function stringContainsExpression(testString: string): boolean {
return /^=.*\{\{.*\}\}/.test(testString);

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
@@ -37,7 +38,6 @@ import { getCredentialExportPath, getWorkflowExportPath } from './source-control
import type { ExportableCredential } from './types/exportable-credential';
import type { ResourceOwner } from './types/resource-owner';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
import type { SourceControlledFile } from './types/source-controlled-file';
import { VariablesService } from '../variables/variables.service.ee';
@Service()

View File

@@ -1,9 +1,12 @@
import { PullWorkFolderRequestDto, PushWorkFolderRequestDto } from '@n8n/api-types';
import type { SourceControlledFile } from '@n8n/api-types';
import express from 'express';
import type { PullResult } from 'simple-git';
import { Get, Post, Patch, RestController, GlobalScope } from '@/decorators';
import { Get, Post, Patch, RestController, GlobalScope, Body } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { AuthenticatedRequest } from '@/requests';
import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
import {
@@ -17,7 +20,6 @@ import type { ImportResult } from './types/import-result';
import { SourceControlRequest } from './types/requests';
import { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlPreferences } from './types/source-control-preferences';
import type { SourceControlledFile } from './types/source-controlled-file';
@RestController('/source-control')
export class SourceControlController {
@@ -164,19 +166,16 @@ export class SourceControlController {
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@GlobalScope('sourceControl:push')
async pushWorkfolder(
req: SourceControlRequest.PushWorkFolder,
req: AuthenticatedRequest,
res: express.Response,
@Body payload: PushWorkFolderRequestDto,
): Promise<SourceControlledFile[]> {
if (this.sourceControlPreferencesService.isBranchReadOnly()) {
throw new BadRequestError('Cannot push onto read-only branch.');
}
try {
await this.sourceControlService.setGitUserDetails(
`${req.user.firstName} ${req.user.lastName}`,
req.user.email,
);
const result = await this.sourceControlService.pushWorkfolder(req.body);
const result = await this.sourceControlService.pushWorkfolder(payload);
res.statusCode = result.statusCode;
return result.statusResult;
} catch (error) {
@@ -187,15 +186,12 @@ export class SourceControlController {
@Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@GlobalScope('sourceControl:pull')
async pullWorkfolder(
req: SourceControlRequest.PullWorkFolder,
req: AuthenticatedRequest,
res: express.Response,
@Body payload: PullWorkFolderRequestDto,
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
try {
const result = await this.sourceControlService.pullWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
});
const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload);
res.statusCode = result.statusCode;
return result.statusResult;
} catch (error) {

View File

@@ -1,3 +1,8 @@
import type {
PullWorkFolderRequestDto,
PushWorkFolderRequestDto,
SourceControlledFile,
} from '@n8n/api-types';
import { Service } from '@n8n/di';
import { writeFileSync } from 'fs';
import { Logger } from 'n8n-core';
@@ -34,10 +39,7 @@ import type { ExportableCredential } from './types/exportable-credential';
import type { ImportResult } from './types/import-result';
import type { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlPreferences } from './types/source-control-preferences';
import type { SourceControllPullOptions } from './types/source-control-pull-work-folder';
import type { SourceControlPushWorkFolder } from './types/source-control-push-work-folder';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
import type { SourceControlledFile } from './types/source-controlled-file';
@Service()
export class SourceControlService {
@@ -207,7 +209,7 @@ export class SourceControlService {
return;
}
async pushWorkfolder(options: SourceControlPushWorkFolder): Promise<{
async pushWorkfolder(options: PushWorkFolderRequestDto): Promise<{
statusCode: number;
pushResult: PushResult | undefined;
statusResult: SourceControlledFile[];
@@ -299,7 +301,7 @@ export class SourceControlService {
}
}
await this.gitService.commit(options.message ?? 'Updated Workfolder');
await this.gitService.commit(options.commitMessage ?? 'Updated Workfolder');
const pushResult = await this.gitService.push({
branch: this.sourceControlPreferencesService.getBranchName(),
@@ -321,7 +323,8 @@ export class SourceControlService {
}
async pullWorkfolder(
options: SourceControllPullOptions,
userId: User['id'],
options: PullWorkFolderRequestDto,
): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> {
await this.sanityCheck();
@@ -345,7 +348,7 @@ export class SourceControlService {
return true;
});
if (options.force !== true) {
if (!options.force) {
const possibleConflicts = filteredResult?.filter(
(file) => (file.conflict || file.status === 'modified') && file.type === 'workflow',
);
@@ -363,7 +366,7 @@ export class SourceControlService {
);
await this.sourceControlImportService.importWorkflowFromWorkFolder(
workflowsToBeImported,
options.userId,
userId,
);
const credentialsToBeImported = statusResult.filter(
@@ -371,7 +374,7 @@ export class SourceControlService {
);
await this.sourceControlImportService.importCredentialsFromWorkFolder(
credentialsToBeImported,
options.userId,
userId,
);
const tagsToBeImported = statusResult.find((e) => e.type === 'tags');

View File

@@ -5,9 +5,7 @@ import type { SourceControlDisconnect } from './source-control-disconnect';
import type { SourceControlGenerateKeyPair } from './source-control-generate-key-pair';
import type { SourceControlGetStatus } from './source-control-get-status';
import type { SourceControlPreferences } from './source-control-preferences';
import type { SourceControlPullWorkFolder } from './source-control-pull-work-folder';
import type { SourceControlPush } from './source-control-push';
import type { SourceControlPushWorkFolder } from './source-control-push-work-folder';
import type { SourceControlSetBranch } from './source-control-set-branch';
import type { SourceControlSetReadOnly } from './source-control-set-read-only';
import type { SourceControlStage } from './source-control-stage';
@@ -20,8 +18,6 @@ export declare namespace SourceControlRequest {
type Stage = AuthenticatedRequest<{}, {}, SourceControlStage, {}>;
type Push = AuthenticatedRequest<{}, {}, SourceControlPush, {}>;
type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>;
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
type GenerateKeyPair = AuthenticatedRequest<{}, {}, SourceControlGenerateKeyPair, {}>;
}

View File

@@ -1,28 +0,0 @@
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
export class SourceControlPullWorkFolder {
@IsBoolean()
@IsOptional()
force?: boolean;
@IsBoolean()
@IsOptional()
importAfterPull?: boolean = true;
@IsString({ each: true })
@IsOptional()
files?: Set<string>;
@IsObject()
@IsOptional()
variables?: { [key: string]: string };
}
export class SourceControllPullOptions {
/** ID of user performing a source control pull. */
userId: string;
force?: boolean;
variables?: { [key: string]: string };
}

View File

@@ -1,20 +0,0 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import type { SourceControlledFile } from './source-controlled-file';
export class SourceControlPushWorkFolder {
@IsBoolean()
@IsOptional()
force?: boolean;
@IsString({ each: true })
fileNames: SourceControlledFile[];
@IsString()
@IsOptional()
message?: string;
@IsBoolean()
@IsOptional()
skipDiff?: boolean;
}

View File

@@ -1,23 +0,0 @@
export type SourceControlledFileStatus =
| 'new'
| 'modified'
| 'deleted'
| 'created'
| 'renamed'
| 'conflicted'
| 'ignored'
| 'staged'
| 'unknown';
export type SourceControlledFileLocation = 'local' | 'remote';
export type SourceControlledFileType = 'credential' | 'workflow' | 'tags' | 'variables' | 'file';
export type SourceControlledFile = {
file: string;
id: string;
name: string;
type: SourceControlledFileType;
status: SourceControlledFileStatus;
location: SourceControlledFileLocation;
conflict: boolean;
updatedAt: string;
pushed?: boolean;
};

View File

@@ -173,16 +173,6 @@ export interface IJsonSchema {
required: string[];
}
export class SourceControlPull {
force?: boolean;
variables?: { [key: string]: string };
}
export declare namespace PublicSourceControlRequest {
type Pull = AuthenticatedRequest<{}, {}, SourceControlPull, {}>;
}
// ----------------------------------
// /audit
// ----------------------------------

View File

@@ -1,3 +1,4 @@
import { PullWorkFolderRequestDto } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type express from 'express';
import type { StatusResult } from 'simple-git';
@@ -10,15 +11,15 @@ import { SourceControlPreferencesService } from '@/environments.ee/source-contro
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { ImportResult } from '@/environments.ee/source-control/types/import-result';
import { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
import type { PublicSourceControlRequest } from '../../../types';
import { globalScope } from '../../shared/middlewares/global.middleware';
export = {
pull: [
globalScope('sourceControl:pull'),
async (
req: PublicSourceControlRequest.Pull,
req: AuthenticatedRequest,
res: express.Response,
): Promise<ImportResult | StatusResult | Promise<express.Response>> => {
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
@@ -33,17 +34,14 @@ export = {
.json({ status: 'Error', message: 'Source Control is not connected to a repository' });
}
try {
const payload = PullWorkFolderRequestDto.parse(req.body);
const sourceControlService = Container.get(SourceControlService);
const result = await sourceControlService.pullWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
});
const result = await sourceControlService.pullWorkfolder(req.user.id, payload);
if (result.statusCode === 200) {
Container.get(EventService).emit('source-control-user-pulled-api', {
...getTrackingInformationFromPullResult(result.statusResult),
forced: req.body.force ?? false,
forced: payload.force ?? false,
});
return res.status(200).send(result.statusResult);
} else {

View File

@@ -1,3 +1,4 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
@@ -11,7 +12,6 @@ import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file';
import { mockInstance } from '../../shared/mocking';
import { saveCredential } from '../shared/db/credentials';

View File

@@ -1,10 +1,10 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import config from '@/config';
import type { User } from '@/databases/entities/user';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { SourceControlledFile } from '@/environments.ee/source-control/types/source-controlled-file';
import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking';

View File

@@ -1,7 +1,10 @@
import type { IDataObject } from 'n8n-workflow';
import type {
PullWorkFolderRequestDto,
PushWorkFolderRequestDto,
SourceControlledFile,
} from '@n8n/api-types';
import type { IRestApiContext } from '@/Interface';
import type {
SourceControlAggregatedFile,
SourceControlPreferences,
SourceControlStatus,
SshKeyTypes,
@@ -22,14 +25,14 @@ const createPreferencesRequestFn =
export const pushWorkfolder = async (
context: IRestApiContext,
data: IDataObject,
data: PushWorkFolderRequestDto,
): Promise<void> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/push-workfolder`, data);
};
export const pullWorkfolder = async (
context: IRestApiContext,
data: IDataObject,
data: PullWorkFolderRequestDto,
): Promise<void> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
};
@@ -60,7 +63,7 @@ export const getAggregatedStatus = async (
preferLocalVersion: boolean;
verbose: boolean;
} = { direction: 'push', preferLocalVersion: true, verbose: false },
): Promise<SourceControlAggregatedFile[]> => {
): Promise<SourceControlledFile[]> => {
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options);
};

View File

@@ -8,8 +8,8 @@ import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { sourceControlEventBus } from '@/event-bus/source-control';
import type { SourceControlledFile } from '@n8n/api-types';
defineProps<{
isCollapsed: boolean;
@@ -69,10 +69,8 @@ async function pullWorkfolder() {
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try {
const status: SourceControlAggregatedFile[] =
((await sourceControlStore.pullWorkfolder(
false,
)) as unknown as SourceControlAggregatedFile[]) || [];
const status: SourceControlledFile[] =
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');

View File

@@ -2,7 +2,6 @@
import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast';
@@ -10,9 +9,10 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { computed, nextTick, ref } from 'vue';
import { sourceControlEventBus } from '@/event-bus/source-control';
import type { SourceControlledFile } from '@n8n/api-types';
const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
data: { eventBus: EventBus; status: SourceControlledFile[] };
}>();
const incompleteFileTypes = ['variables', 'credential'];
@@ -23,7 +23,7 @@ const toast = useToast();
const i18n = useI18n();
const sourceControlStore = useSourceControlStore();
const files = ref<SourceControlAggregatedFile[]>(props.data.status || []);
const files = ref<SourceControlledFile[]>(props.data.status || []);
const workflowFiles = computed(() => {
return files.value.filter((file) => file.type === 'workflow');

View File

@@ -5,7 +5,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system';
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import type { SourceControlledFile } from '@n8n/api-types';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
@@ -71,7 +71,7 @@ describe('SourceControlPushModal', () => {
});
it('should toggle checkboxes', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
@@ -160,7 +160,7 @@ describe('SourceControlPushModal', () => {
});
it('should push non workflow entities', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'credential',
@@ -226,7 +226,7 @@ describe('SourceControlPushModal', () => {
});
it('should auto select currentWorkflow', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
@@ -276,7 +276,7 @@ describe('SourceControlPushModal', () => {
describe('filters', () => {
it('should filter by name', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
@@ -317,7 +317,7 @@ describe('SourceControlPushModal', () => {
});
it('should filter by status', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'Created Workflow',
@@ -375,7 +375,7 @@ describe('SourceControlPushModal', () => {
});
it('should reset', async () => {
const status: SourceControlAggregatedFile[] = [
const status: SourceControlledFile[] = [
{
id: 'JIGKevgZagmJAnM6',
name: 'Modified workflow',

View File

@@ -31,16 +31,15 @@ import {
N8nInfoTip,
} from 'n8n-design-system';
import {
type SourceControlledFile,
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
SOURCE_CONTROL_FILE_LOCATION,
type SourceControlledFileStatus,
type SourceControlAggregatedFile,
} from '@/types/sourceControl.types';
} from '@n8n/api-types';
import { orderBy, groupBy } from 'lodash-es';
const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
data: { eventBus: EventBus; status: SourceControlledFile[] };
}>();
const loadingService = useLoadingService();
@@ -50,49 +49,48 @@ const i18n = useI18n();
const sourceControlStore = useSourceControlStore();
const route = useRoute();
type SourceControlledFileStatus = SourceControlledFile['status'];
type Changes = {
tags: SourceControlAggregatedFile[];
variables: SourceControlAggregatedFile[];
credentials: SourceControlAggregatedFile[];
workflows: SourceControlAggregatedFile[];
currentWorkflow?: SourceControlAggregatedFile;
tags: SourceControlledFile[];
variables: SourceControlledFile[];
credentials: SourceControlledFile[];
workflows: SourceControlledFile[];
currentWorkflow?: SourceControlledFile;
};
const classifyFilesByType = (
files: SourceControlAggregatedFile[],
currentWorkflowId?: string,
): Changes =>
const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes =>
files.reduce<Changes>(
(acc, file) => {
// do not show remote workflows that are not yet created locally during push
if (
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
file.location === SOURCE_CONTROL_FILE_LOCATION.remote &&
file.type === SOURCE_CONTROL_FILE_TYPE.workflow &&
file.status === SOURCE_CONTROL_FILE_STATUS.created
) {
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
if (file.type === SOURCE_CONTROL_FILE_TYPE.variables) {
acc.variables.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
if (file.type === SOURCE_CONTROL_FILE_TYPE.tags) {
acc.tags.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW && currentWorkflowId === file.id) {
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow && currentWorkflowId === file.id) {
acc.currentWorkflow = file;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) {
acc.workflows.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) {
acc.credentials.push(file);
return acc;
}
@@ -139,7 +137,7 @@ const toggleSelected = (id: string) => {
}
};
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFile) =>
workflow && selectedChanges.value.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
@@ -152,15 +150,15 @@ const resetFilters = () => {
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
{
label: 'New',
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
value: SOURCE_CONTROL_FILE_STATUS.created,
},
{
label: 'Modified',
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
value: SOURCE_CONTROL_FILE_STATUS.modified,
},
{
label: 'Deleted',
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
value: SOURCE_CONTROL_FILE_STATUS.deleted,
},
] as const;
@@ -188,10 +186,10 @@ const filteredWorkflows = computed(() => {
});
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
[SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
[SOURCE_CONTROL_FILE_STATUS.modified]: 1,
[SOURCE_CONTROL_FILE_STATUS.renamed]: 2,
[SOURCE_CONTROL_FILE_STATUS.created]: 3,
[SOURCE_CONTROL_FILE_STATUS.deleted]: 4,
} as const;
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
statusPriority[status] ?? 0;
@@ -250,7 +248,7 @@ function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
}
function renderUpdatedAt(file: SourceControlAggregatedFile) {
function renderUpdatedAt(file: SourceControlledFile) {
const currentYear = new Date().getFullYear().toString();
return i18n.baseText('settings.sourceControl.lastUpdated', {
@@ -338,9 +336,9 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
> = {
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
[SOURCE_CONTROL_FILE_STATUS.created]: 'success',
[SOURCE_CONTROL_FILE_STATUS.deleted]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.modified]: 'warning',
} as const;
return statusToBadgeThemeMap[status];
};
@@ -454,11 +452,11 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
@update:model-value="toggleSelected(file.id)"
>
<span>
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.deleted" color="text-light">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow">
Deleted Workflow:
</span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential">
Deleted Credential:
</span>
<strong>{{ file.name || file.id }}</strong>

View File

@@ -6,6 +6,7 @@ import { useRootStore } from '@/stores/root.store';
import * as vcApi from '@/api/sourceControl';
import type { SourceControlPreferences, SshKeyTypes } from '@/types/sourceControl.types';
import type { TupleToUnion } from '@/utils/typeHelpers';
import type { SourceControlledFile } from '@n8n/api-types';
export const useSourceControlStore = defineStore('sourceControl', () => {
const rootStore = useRootStore();
@@ -39,23 +40,14 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
const pushWorkfolder = async (data: {
commitMessage: string;
fileNames?: Array<{
conflict: boolean;
file: string;
id: string;
location: string;
name: string;
status: string;
type: string;
updatedAt?: string | undefined;
}>;
fileNames: SourceControlledFile[];
force: boolean;
}) => {
state.commitMessage = data.commitMessage;
await vcApi.pushWorkfolder(rootStore.restApiContext, {
force: data.force,
message: data.commitMessage,
...(data.fileNames ? { fileNames: data.fileNames } : {}),
commitMessage: data.commitMessage,
fileNames: data.fileNames,
});
};

View File

@@ -1,37 +1,5 @@
import type { TupleToUnion } from '@/utils/typeHelpers';
export const SOURCE_CONTROL_FILE_STATUS = {
NEW: 'new',
MODIFIED: 'modified',
DELETED: 'deleted',
CREATED: 'created',
RENAMED: 'renamed',
CONFLICTED: 'conflicted',
IGNORED: 'ignored',
STAGED: 'staged',
UNKNOWN: 'unknown',
} as const;
export const SOURCE_CONTROL_FILE_LOCATION = {
LOCAL: 'local',
REMOTE: 'remote',
} as const;
export const SOURCE_CONTROL_FILE_TYPE = {
CREDENTIAL: 'credential',
WORKFLOW: 'workflow',
TAGS: 'tags',
VARIABLES: 'variables',
FILE: 'file',
} as const;
export type SourceControlledFileStatus =
(typeof SOURCE_CONTROL_FILE_STATUS)[keyof typeof SOURCE_CONTROL_FILE_STATUS];
export type SourceControlledFileLocation =
(typeof SOURCE_CONTROL_FILE_LOCATION)[keyof typeof SOURCE_CONTROL_FILE_LOCATION];
export type SourceControlledFileType =
(typeof SOURCE_CONTROL_FILE_TYPE)[keyof typeof SOURCE_CONTROL_FILE_TYPE];
export type SshKeyTypes = ['ed25519', 'rsa'];
export type SourceControlPreferences = {
@@ -65,14 +33,3 @@ export interface SourceControlStatus {
staged: string[];
tracking: null;
}
export interface SourceControlAggregatedFile {
conflict: boolean;
file: string;
id: string;
location: SourceControlledFileLocation;
name: string;
status: SourceControlledFileStatus;
type: SourceControlledFileType;
updatedAt?: string;
}