mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
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:
committed by
GitHub
parent
ecff3b732a
commit
1d86c4fdd2
@@ -36,6 +36,9 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto';
|
|||||||
|
|
||||||
export { CommunityRegisteredRequestDto } from './license/community-registered-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 { VariableListRequestDto } from './variables/variables-list-request.dto';
|
||||||
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
|
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
|
||||||
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
|
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
|
export class PullWorkFolderRequestDto extends Z.class({
|
||||||
|
force: z.boolean().optional(),
|
||||||
|
}) {}
|
||||||
@@ -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),
|
||||||
|
}) {}
|
||||||
@@ -10,9 +10,17 @@ export type { SendWorkerStatusMessage } from './push/worker';
|
|||||||
|
|
||||||
export type { BannerName } from './schemas/bannerName.schema';
|
export type { BannerName } from './schemas/bannerName.schema';
|
||||||
export { passwordSchema } from './schemas/password.schema';
|
export { passwordSchema } from './schemas/password.schema';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ProjectType,
|
ProjectType,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectRelation,
|
ProjectRelation,
|
||||||
} from './schemas/project.schema';
|
} 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';
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import mock from 'jest-mock-extended/lib/Mock';
|
import mock from 'jest-mock-extended/lib/Mock';
|
||||||
import { Cipher, type InstanceSettings } from 'n8n-core';
|
import { Cipher, type InstanceSettings } from 'n8n-core';
|
||||||
@@ -9,7 +10,6 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre
|
|||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
import { SourceControlExportService } from '../source-control-export.service.ee';
|
import { SourceControlExportService } from '../source-control-export.service.ee';
|
||||||
import type { SourceControlledFile } from '../types/source-controlled-file';
|
|
||||||
|
|
||||||
// https://github.com/jestjs/jest/issues/4715
|
// https://github.com/jestjs/jest/issues/4715
|
||||||
function deepSpyOn<O extends object, M extends keyof O>(object: O, methodName: M) {
|
function deepSpyOn<O extends object, M extends keyof O>(object: O, methodName: M) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { constants as fsConstants, accessSync } from 'fs';
|
import { constants as fsConstants, accessSync } from 'fs';
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
} from '@/environments.ee/source-control/source-control-helper.ee';
|
} from '@/environments.ee/source-control/source-control-helper.ee';
|
||||||
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.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 { 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 { License } from '@/license';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import { rmSync } from 'fs';
|
import { rmSync } from 'fs';
|
||||||
import { Credentials, InstanceSettings, Logger } from 'n8n-core';
|
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 { ExportableCredential } from './types/exportable-credential';
|
||||||
import type { ExportableWorkflow } from './types/exportable-workflow';
|
import type { ExportableWorkflow } from './types/exportable-workflow';
|
||||||
import type { ResourceOwner } from './types/resource-owner';
|
import type { ResourceOwner } from './types/resource-owner';
|
||||||
import type { SourceControlledFile } from './types/source-controlled-file';
|
|
||||||
import { VariablesService } from '../variables/variables.service.ee';
|
import { VariablesService } from '../variables/variables.service.ee';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { generateKeyPairSync } from 'crypto';
|
import { generateKeyPairSync } from 'crypto';
|
||||||
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
|
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
} from './constants';
|
} from './constants';
|
||||||
import type { KeyPair } from './types/key-pair';
|
import type { KeyPair } from './types/key-pair';
|
||||||
import type { KeyPairType } from './types/key-pair-type';
|
import type { KeyPairType } from './types/key-pair-type';
|
||||||
import type { SourceControlledFile } from './types/source-controlled-file';
|
|
||||||
|
|
||||||
export function stringContainsExpression(testString: string): boolean {
|
export function stringContainsExpression(testString: string): boolean {
|
||||||
return /^=.*\{\{.*\}\}/.test(testString);
|
return /^=.*\{\{.*\}\}/.test(testString);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In } from '@n8n/typeorm';
|
import { In } from '@n8n/typeorm';
|
||||||
@@ -37,7 +38,6 @@ import { getCredentialExportPath, getWorkflowExportPath } from './source-control
|
|||||||
import type { ExportableCredential } from './types/exportable-credential';
|
import type { ExportableCredential } from './types/exportable-credential';
|
||||||
import type { ResourceOwner } from './types/resource-owner';
|
import type { ResourceOwner } from './types/resource-owner';
|
||||||
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
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';
|
import { VariablesService } from '../variables/variables.service.ee';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { PullWorkFolderRequestDto, PushWorkFolderRequestDto } from '@n8n/api-types';
|
||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { PullResult } from 'simple-git';
|
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 { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
|
import { AuthenticatedRequest } from '@/requests';
|
||||||
|
|
||||||
import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
|
import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +20,6 @@ import type { ImportResult } from './types/import-result';
|
|||||||
import { SourceControlRequest } from './types/requests';
|
import { SourceControlRequest } from './types/requests';
|
||||||
import { SourceControlGetStatus } from './types/source-control-get-status';
|
import { SourceControlGetStatus } from './types/source-control-get-status';
|
||||||
import type { SourceControlPreferences } from './types/source-control-preferences';
|
import type { SourceControlPreferences } from './types/source-control-preferences';
|
||||||
import type { SourceControlledFile } from './types/source-controlled-file';
|
|
||||||
|
|
||||||
@RestController('/source-control')
|
@RestController('/source-control')
|
||||||
export class SourceControlController {
|
export class SourceControlController {
|
||||||
@@ -164,19 +166,16 @@ export class SourceControlController {
|
|||||||
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
|
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
|
||||||
@GlobalScope('sourceControl:push')
|
@GlobalScope('sourceControl:push')
|
||||||
async pushWorkfolder(
|
async pushWorkfolder(
|
||||||
req: SourceControlRequest.PushWorkFolder,
|
req: AuthenticatedRequest,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
|
@Body payload: PushWorkFolderRequestDto,
|
||||||
): Promise<SourceControlledFile[]> {
|
): Promise<SourceControlledFile[]> {
|
||||||
if (this.sourceControlPreferencesService.isBranchReadOnly()) {
|
|
||||||
throw new BadRequestError('Cannot push onto read-only branch.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sourceControlService.setGitUserDetails(
|
await this.sourceControlService.setGitUserDetails(
|
||||||
`${req.user.firstName} ${req.user.lastName}`,
|
`${req.user.firstName} ${req.user.lastName}`,
|
||||||
req.user.email,
|
req.user.email,
|
||||||
);
|
);
|
||||||
const result = await this.sourceControlService.pushWorkfolder(req.body);
|
const result = await this.sourceControlService.pushWorkfolder(payload);
|
||||||
res.statusCode = result.statusCode;
|
res.statusCode = result.statusCode;
|
||||||
return result.statusResult;
|
return result.statusResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -187,15 +186,12 @@ export class SourceControlController {
|
|||||||
@Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
|
@Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
|
||||||
@GlobalScope('sourceControl:pull')
|
@GlobalScope('sourceControl:pull')
|
||||||
async pullWorkfolder(
|
async pullWorkfolder(
|
||||||
req: SourceControlRequest.PullWorkFolder,
|
req: AuthenticatedRequest,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
|
@Body payload: PullWorkFolderRequestDto,
|
||||||
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
|
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
|
||||||
try {
|
try {
|
||||||
const result = await this.sourceControlService.pullWorkfolder({
|
const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload);
|
||||||
force: req.body.force,
|
|
||||||
variables: req.body.variables,
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
res.statusCode = result.statusCode;
|
res.statusCode = result.statusCode;
|
||||||
return result.statusResult;
|
return result.statusResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
PullWorkFolderRequestDto,
|
||||||
|
PushWorkFolderRequestDto,
|
||||||
|
SourceControlledFile,
|
||||||
|
} from '@n8n/api-types';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
import { Logger } from 'n8n-core';
|
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 { ImportResult } from './types/import-result';
|
||||||
import type { SourceControlGetStatus } from './types/source-control-get-status';
|
import type { SourceControlGetStatus } from './types/source-control-get-status';
|
||||||
import type { SourceControlPreferences } from './types/source-control-preferences';
|
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 { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
|
||||||
import type { SourceControlledFile } from './types/source-controlled-file';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SourceControlService {
|
export class SourceControlService {
|
||||||
@@ -207,7 +209,7 @@ export class SourceControlService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pushWorkfolder(options: SourceControlPushWorkFolder): Promise<{
|
async pushWorkfolder(options: PushWorkFolderRequestDto): Promise<{
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
pushResult: PushResult | undefined;
|
pushResult: PushResult | undefined;
|
||||||
statusResult: SourceControlledFile[];
|
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({
|
const pushResult = await this.gitService.push({
|
||||||
branch: this.sourceControlPreferencesService.getBranchName(),
|
branch: this.sourceControlPreferencesService.getBranchName(),
|
||||||
@@ -321,7 +323,8 @@ export class SourceControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pullWorkfolder(
|
async pullWorkfolder(
|
||||||
options: SourceControllPullOptions,
|
userId: User['id'],
|
||||||
|
options: PullWorkFolderRequestDto,
|
||||||
): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> {
|
): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> {
|
||||||
await this.sanityCheck();
|
await this.sanityCheck();
|
||||||
|
|
||||||
@@ -345,7 +348,7 @@ export class SourceControlService {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.force !== true) {
|
if (!options.force) {
|
||||||
const possibleConflicts = filteredResult?.filter(
|
const possibleConflicts = filteredResult?.filter(
|
||||||
(file) => (file.conflict || file.status === 'modified') && file.type === 'workflow',
|
(file) => (file.conflict || file.status === 'modified') && file.type === 'workflow',
|
||||||
);
|
);
|
||||||
@@ -363,7 +366,7 @@ export class SourceControlService {
|
|||||||
);
|
);
|
||||||
await this.sourceControlImportService.importWorkflowFromWorkFolder(
|
await this.sourceControlImportService.importWorkflowFromWorkFolder(
|
||||||
workflowsToBeImported,
|
workflowsToBeImported,
|
||||||
options.userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const credentialsToBeImported = statusResult.filter(
|
const credentialsToBeImported = statusResult.filter(
|
||||||
@@ -371,7 +374,7 @@ export class SourceControlService {
|
|||||||
);
|
);
|
||||||
await this.sourceControlImportService.importCredentialsFromWorkFolder(
|
await this.sourceControlImportService.importCredentialsFromWorkFolder(
|
||||||
credentialsToBeImported,
|
credentialsToBeImported,
|
||||||
options.userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagsToBeImported = statusResult.find((e) => e.type === 'tags');
|
const tagsToBeImported = statusResult.find((e) => e.type === 'tags');
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import type { SourceControlDisconnect } from './source-control-disconnect';
|
|||||||
import type { SourceControlGenerateKeyPair } from './source-control-generate-key-pair';
|
import type { SourceControlGenerateKeyPair } from './source-control-generate-key-pair';
|
||||||
import type { SourceControlGetStatus } from './source-control-get-status';
|
import type { SourceControlGetStatus } from './source-control-get-status';
|
||||||
import type { SourceControlPreferences } from './source-control-preferences';
|
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 { SourceControlPush } from './source-control-push';
|
||||||
import type { SourceControlPushWorkFolder } from './source-control-push-work-folder';
|
|
||||||
import type { SourceControlSetBranch } from './source-control-set-branch';
|
import type { SourceControlSetBranch } from './source-control-set-branch';
|
||||||
import type { SourceControlSetReadOnly } from './source-control-set-read-only';
|
import type { SourceControlSetReadOnly } from './source-control-set-read-only';
|
||||||
import type { SourceControlStage } from './source-control-stage';
|
import type { SourceControlStage } from './source-control-stage';
|
||||||
@@ -20,8 +18,6 @@ export declare namespace SourceControlRequest {
|
|||||||
type Stage = AuthenticatedRequest<{}, {}, SourceControlStage, {}>;
|
type Stage = AuthenticatedRequest<{}, {}, SourceControlStage, {}>;
|
||||||
type Push = AuthenticatedRequest<{}, {}, SourceControlPush, {}>;
|
type Push = AuthenticatedRequest<{}, {}, SourceControlPush, {}>;
|
||||||
type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>;
|
type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>;
|
||||||
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
|
|
||||||
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
|
|
||||||
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
|
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
|
||||||
type GenerateKeyPair = AuthenticatedRequest<{}, {}, SourceControlGenerateKeyPair, {}>;
|
type GenerateKeyPair = AuthenticatedRequest<{}, {}, SourceControlGenerateKeyPair, {}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -173,16 +173,6 @@ export interface IJsonSchema {
|
|||||||
required: string[];
|
required: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SourceControlPull {
|
|
||||||
force?: boolean;
|
|
||||||
|
|
||||||
variables?: { [key: string]: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare namespace PublicSourceControlRequest {
|
|
||||||
type Pull = AuthenticatedRequest<{}, {}, SourceControlPull, {}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// /audit
|
// /audit
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PullWorkFolderRequestDto } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import type { StatusResult } from 'simple-git';
|
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 { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
|
||||||
import type { ImportResult } from '@/environments.ee/source-control/types/import-result';
|
import type { ImportResult } from '@/environments.ee/source-control/types/import-result';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
|
||||||
import type { PublicSourceControlRequest } from '../../../types';
|
|
||||||
import { globalScope } from '../../shared/middlewares/global.middleware';
|
import { globalScope } from '../../shared/middlewares/global.middleware';
|
||||||
|
|
||||||
export = {
|
export = {
|
||||||
pull: [
|
pull: [
|
||||||
globalScope('sourceControl:pull'),
|
globalScope('sourceControl:pull'),
|
||||||
async (
|
async (
|
||||||
req: PublicSourceControlRequest.Pull,
|
req: AuthenticatedRequest,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
): Promise<ImportResult | StatusResult | Promise<express.Response>> => {
|
): Promise<ImportResult | StatusResult | Promise<express.Response>> => {
|
||||||
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
|
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
|
||||||
@@ -33,17 +34,14 @@ export = {
|
|||||||
.json({ status: 'Error', message: 'Source Control is not connected to a repository' });
|
.json({ status: 'Error', message: 'Source Control is not connected to a repository' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const payload = PullWorkFolderRequestDto.parse(req.body);
|
||||||
const sourceControlService = Container.get(SourceControlService);
|
const sourceControlService = Container.get(SourceControlService);
|
||||||
const result = await sourceControlService.pullWorkfolder({
|
const result = await sourceControlService.pullWorkfolder(req.user.id, payload);
|
||||||
force: req.body.force,
|
|
||||||
variables: req.body.variables,
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode === 200) {
|
if (result.statusCode === 200) {
|
||||||
Container.get(EventService).emit('source-control-user-pulled-api', {
|
Container.get(EventService).emit('source-control-user-pulled-api', {
|
||||||
...getTrackingInformationFromPullResult(result.statusResult),
|
...getTrackingInformationFromPullResult(result.statusResult),
|
||||||
forced: req.body.force ?? false,
|
forced: payload.force ?? false,
|
||||||
});
|
});
|
||||||
return res.status(200).send(result.statusResult);
|
return res.status(200).send(result.statusResult);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { Cipher } from 'n8n-core';
|
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 { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
||||||
import { SourceControlImportService } from '@/environments.ee/source-control/source-control-import.service.ee';
|
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 { 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 { mockInstance } from '../../shared/mocking';
|
||||||
import { saveCredential } from '../shared/db/credentials';
|
import { saveCredential } from '../shared/db/credentials';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.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 { 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 { Telemetry } from '@/telemetry';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
|
|||||||
@@ -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 { IRestApiContext } from '@/Interface';
|
||||||
import type {
|
import type {
|
||||||
SourceControlAggregatedFile,
|
|
||||||
SourceControlPreferences,
|
SourceControlPreferences,
|
||||||
SourceControlStatus,
|
SourceControlStatus,
|
||||||
SshKeyTypes,
|
SshKeyTypes,
|
||||||
@@ -22,14 +25,14 @@ const createPreferencesRequestFn =
|
|||||||
|
|
||||||
export const pushWorkfolder = async (
|
export const pushWorkfolder = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: IDataObject,
|
data: PushWorkFolderRequestDto,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/push-workfolder`, data);
|
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/push-workfolder`, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pullWorkfolder = async (
|
export const pullWorkfolder = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: IDataObject,
|
data: PullWorkFolderRequestDto,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
|
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
|
||||||
};
|
};
|
||||||
@@ -60,7 +63,7 @@ export const getAggregatedStatus = async (
|
|||||||
preferLocalVersion: boolean;
|
preferLocalVersion: boolean;
|
||||||
verbose: boolean;
|
verbose: boolean;
|
||||||
} = { direction: 'push', preferLocalVersion: true, verbose: false },
|
} = { direction: 'push', preferLocalVersion: true, verbose: false },
|
||||||
): Promise<SourceControlAggregatedFile[]> => {
|
): Promise<SourceControlledFile[]> => {
|
||||||
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options);
|
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { useLoadingService } from '@/composables/useLoadingService';
|
|||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
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 { sourceControlEventBus } from '@/event-bus/source-control';
|
||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
@@ -69,10 +69,8 @@ async function pullWorkfolder() {
|
|||||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status: SourceControlAggregatedFile[] =
|
const status: SourceControlledFile[] =
|
||||||
((await sourceControlStore.pullWorkfolder(
|
((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
|
||||||
false,
|
|
||||||
)) as unknown as SourceControlAggregatedFile[]) || [];
|
|
||||||
|
|
||||||
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
|
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
|
||||||
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
|
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
|
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useLoadingService } from '@/composables/useLoadingService';
|
import { useLoadingService } from '@/composables/useLoadingService';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
@@ -10,9 +9,10 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { computed, nextTick, ref } from 'vue';
|
import { computed, nextTick, ref } from 'vue';
|
||||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const incompleteFileTypes = ['variables', 'credential'];
|
const incompleteFileTypes = ['variables', 'credential'];
|
||||||
@@ -23,7 +23,7 @@ const toast = useToast();
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
|
||||||
const files = ref<SourceControlAggregatedFile[]>(props.data.status || []);
|
const files = ref<SourceControlledFile[]>(props.data.status || []);
|
||||||
|
|
||||||
const workflowFiles = computed(() => {
|
const workflowFiles = computed(() => {
|
||||||
return files.value.filter((file) => file.type === 'workflow');
|
return files.value.filter((file) => file.type === 'workflow');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
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 { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
@@ -71,7 +71,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should toggle checkboxes', async () => {
|
it('should toggle checkboxes', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'gTbbBkkYTnNyX1jD',
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
name: 'My workflow 1',
|
name: 'My workflow 1',
|
||||||
@@ -160,7 +160,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should push non workflow entities', async () => {
|
it('should push non workflow entities', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'gTbbBkkYTnNyX1jD',
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
name: 'credential',
|
name: 'credential',
|
||||||
@@ -226,7 +226,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should auto select currentWorkflow', async () => {
|
it('should auto select currentWorkflow', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'gTbbBkkYTnNyX1jD',
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
name: 'My workflow 1',
|
name: 'My workflow 1',
|
||||||
@@ -276,7 +276,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
|
|
||||||
describe('filters', () => {
|
describe('filters', () => {
|
||||||
it('should filter by name', async () => {
|
it('should filter by name', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'gTbbBkkYTnNyX1jD',
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
name: 'My workflow 1',
|
name: 'My workflow 1',
|
||||||
@@ -317,7 +317,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should filter by status', async () => {
|
it('should filter by status', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'gTbbBkkYTnNyX1jD',
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
name: 'Created Workflow',
|
name: 'Created Workflow',
|
||||||
@@ -375,7 +375,7 @@ describe('SourceControlPushModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reset', async () => {
|
it('should reset', async () => {
|
||||||
const status: SourceControlAggregatedFile[] = [
|
const status: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
id: 'JIGKevgZagmJAnM6',
|
id: 'JIGKevgZagmJAnM6',
|
||||||
name: 'Modified workflow',
|
name: 'Modified workflow',
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ import {
|
|||||||
N8nInfoTip,
|
N8nInfoTip,
|
||||||
} from 'n8n-design-system';
|
} from 'n8n-design-system';
|
||||||
import {
|
import {
|
||||||
|
type SourceControlledFile,
|
||||||
SOURCE_CONTROL_FILE_STATUS,
|
SOURCE_CONTROL_FILE_STATUS,
|
||||||
SOURCE_CONTROL_FILE_TYPE,
|
SOURCE_CONTROL_FILE_TYPE,
|
||||||
SOURCE_CONTROL_FILE_LOCATION,
|
SOURCE_CONTROL_FILE_LOCATION,
|
||||||
type SourceControlledFileStatus,
|
} from '@n8n/api-types';
|
||||||
type SourceControlAggregatedFile,
|
|
||||||
} from '@/types/sourceControl.types';
|
|
||||||
import { orderBy, groupBy } from 'lodash-es';
|
import { orderBy, groupBy } from 'lodash-es';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loadingService = useLoadingService();
|
const loadingService = useLoadingService();
|
||||||
@@ -50,49 +49,48 @@ const i18n = useI18n();
|
|||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
type SourceControlledFileStatus = SourceControlledFile['status'];
|
||||||
|
|
||||||
type Changes = {
|
type Changes = {
|
||||||
tags: SourceControlAggregatedFile[];
|
tags: SourceControlledFile[];
|
||||||
variables: SourceControlAggregatedFile[];
|
variables: SourceControlledFile[];
|
||||||
credentials: SourceControlAggregatedFile[];
|
credentials: SourceControlledFile[];
|
||||||
workflows: SourceControlAggregatedFile[];
|
workflows: SourceControlledFile[];
|
||||||
currentWorkflow?: SourceControlAggregatedFile;
|
currentWorkflow?: SourceControlledFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
const classifyFilesByType = (
|
const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes =>
|
||||||
files: SourceControlAggregatedFile[],
|
|
||||||
currentWorkflowId?: string,
|
|
||||||
): Changes =>
|
|
||||||
files.reduce<Changes>(
|
files.reduce<Changes>(
|
||||||
(acc, file) => {
|
(acc, file) => {
|
||||||
// do not show remote workflows that are not yet created locally during push
|
// do not show remote workflows that are not yet created locally during push
|
||||||
if (
|
if (
|
||||||
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
|
file.location === SOURCE_CONTROL_FILE_LOCATION.remote &&
|
||||||
file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
|
file.type === SOURCE_CONTROL_FILE_TYPE.workflow &&
|
||||||
file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
|
file.status === SOURCE_CONTROL_FILE_STATUS.created
|
||||||
) {
|
) {
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.variables) {
|
||||||
acc.variables.push(file);
|
acc.variables.push(file);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.tags) {
|
||||||
acc.tags.push(file);
|
acc.tags.push(file);
|
||||||
return acc;
|
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;
|
acc.currentWorkflow = file;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) {
|
||||||
acc.workflows.push(file);
|
acc.workflows.push(file);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) {
|
||||||
acc.credentials.push(file);
|
acc.credentials.push(file);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,7 @@ const toggleSelected = (id: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
|
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFile) =>
|
||||||
workflow && selectedChanges.value.add(workflow.id);
|
workflow && selectedChanges.value.add(workflow.id);
|
||||||
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
||||||
|
|
||||||
@@ -152,15 +150,15 @@ const resetFilters = () => {
|
|||||||
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
|
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
|
||||||
{
|
{
|
||||||
label: 'New',
|
label: 'New',
|
||||||
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
|
value: SOURCE_CONTROL_FILE_STATUS.created,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Modified',
|
label: 'Modified',
|
||||||
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
|
value: SOURCE_CONTROL_FILE_STATUS.modified,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deleted',
|
label: 'Deleted',
|
||||||
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
|
value: SOURCE_CONTROL_FILE_STATUS.deleted,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -188,10 +186,10 @@ const filteredWorkflows = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
|
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
|
||||||
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
|
[SOURCE_CONTROL_FILE_STATUS.modified]: 1,
|
||||||
[SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
|
[SOURCE_CONTROL_FILE_STATUS.renamed]: 2,
|
||||||
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
|
[SOURCE_CONTROL_FILE_STATUS.created]: 3,
|
||||||
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
|
[SOURCE_CONTROL_FILE_STATUS.deleted]: 4,
|
||||||
} as const;
|
} as const;
|
||||||
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
|
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
|
||||||
statusPriority[status] ?? 0;
|
statusPriority[status] ?? 0;
|
||||||
@@ -250,7 +248,7 @@ function close() {
|
|||||||
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
|
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUpdatedAt(file: SourceControlAggregatedFile) {
|
function renderUpdatedAt(file: SourceControlledFile) {
|
||||||
const currentYear = new Date().getFullYear().toString();
|
const currentYear = new Date().getFullYear().toString();
|
||||||
|
|
||||||
return i18n.baseText('settings.sourceControl.lastUpdated', {
|
return i18n.baseText('settings.sourceControl.lastUpdated', {
|
||||||
@@ -338,9 +336,9 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||||||
const statusToBadgeThemeMap: Partial<
|
const statusToBadgeThemeMap: Partial<
|
||||||
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
|
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
|
||||||
> = {
|
> = {
|
||||||
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
|
[SOURCE_CONTROL_FILE_STATUS.created]: 'success',
|
||||||
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
|
[SOURCE_CONTROL_FILE_STATUS.deleted]: 'danger',
|
||||||
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
|
[SOURCE_CONTROL_FILE_STATUS.modified]: 'warning',
|
||||||
} as const;
|
} as const;
|
||||||
return statusToBadgeThemeMap[status];
|
return statusToBadgeThemeMap[status];
|
||||||
};
|
};
|
||||||
@@ -454,11 +452,11 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||||||
@update:model-value="toggleSelected(file.id)"
|
@update:model-value="toggleSelected(file.id)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
|
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.deleted" color="text-light">
|
||||||
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
|
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow">
|
||||||
Deleted Workflow:
|
Deleted Workflow:
|
||||||
</span>
|
</span>
|
||||||
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
|
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential">
|
||||||
Deleted Credential:
|
Deleted Credential:
|
||||||
</span>
|
</span>
|
||||||
<strong>{{ file.name || file.id }}</strong>
|
<strong>{{ file.name || file.id }}</strong>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRootStore } from '@/stores/root.store';
|
|||||||
import * as vcApi from '@/api/sourceControl';
|
import * as vcApi from '@/api/sourceControl';
|
||||||
import type { SourceControlPreferences, SshKeyTypes } from '@/types/sourceControl.types';
|
import type { SourceControlPreferences, SshKeyTypes } from '@/types/sourceControl.types';
|
||||||
import type { TupleToUnion } from '@/utils/typeHelpers';
|
import type { TupleToUnion } from '@/utils/typeHelpers';
|
||||||
|
import type { SourceControlledFile } from '@n8n/api-types';
|
||||||
|
|
||||||
export const useSourceControlStore = defineStore('sourceControl', () => {
|
export const useSourceControlStore = defineStore('sourceControl', () => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
@@ -39,23 +40,14 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
|
|||||||
|
|
||||||
const pushWorkfolder = async (data: {
|
const pushWorkfolder = async (data: {
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
fileNames?: Array<{
|
fileNames: SourceControlledFile[];
|
||||||
conflict: boolean;
|
|
||||||
file: string;
|
|
||||||
id: string;
|
|
||||||
location: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
type: string;
|
|
||||||
updatedAt?: string | undefined;
|
|
||||||
}>;
|
|
||||||
force: boolean;
|
force: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
state.commitMessage = data.commitMessage;
|
state.commitMessage = data.commitMessage;
|
||||||
await vcApi.pushWorkfolder(rootStore.restApiContext, {
|
await vcApi.pushWorkfolder(rootStore.restApiContext, {
|
||||||
force: data.force,
|
force: data.force,
|
||||||
message: data.commitMessage,
|
commitMessage: data.commitMessage,
|
||||||
...(data.fileNames ? { fileNames: data.fileNames } : {}),
|
fileNames: data.fileNames,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,5 @@
|
|||||||
import type { TupleToUnion } from '@/utils/typeHelpers';
|
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 SshKeyTypes = ['ed25519', 'rsa'];
|
||||||
|
|
||||||
export type SourceControlPreferences = {
|
export type SourceControlPreferences = {
|
||||||
@@ -65,14 +33,3 @@ export interface SourceControlStatus {
|
|||||||
staged: string[];
|
staged: string[];
|
||||||
tracking: null;
|
tracking: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SourceControlAggregatedFile {
|
|
||||||
conflict: boolean;
|
|
||||||
file: string;
|
|
||||||
id: string;
|
|
||||||
location: SourceControlledFileLocation;
|
|
||||||
name: string;
|
|
||||||
status: SourceControlledFileStatus;
|
|
||||||
type: SourceControlledFileType;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user