diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 913bad4b0b0ed..8fe2bc42f5191 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -54,6 +54,14 @@ function userToPayload(user: User): { export class InternalHooks implements IInternalHooksClass { private instanceId: string; + public get telemetryInstanceId(): string { + return this.instanceId; + } + + public get telemetryInstance(): Telemetry { + return this.telemetry; + } + constructor( private telemetry: Telemetry, private nodeTypes: NodeTypes, @@ -1043,4 +1051,53 @@ export class InternalHooks implements IInternalHooksClass { async onVariableCreated(createData: { variable_type: string }): Promise { return this.telemetry.track('User created variable', createData); } + + async onSourceControlSettingsUpdated(data: { + branch_name: string; + read_only_instance: boolean; + repo_type: 'github' | 'gitlab' | 'other'; + connected: boolean; + }): Promise { + return this.telemetry.track('User updated source control settings', data); + } + + async onSourceControlUserStartedPullUI(data: { + workflow_updates: number; + workflow_conflicts: number; + cred_conflicts: number; + }): Promise { + return this.telemetry.track('User started pull via UI', data); + } + + async onSourceControlUserFinishedPullUI(data: { workflow_updates: number }): Promise { + return this.telemetry.track('User finished pull via UI', { + workflow_updates: data.workflow_updates, + }); + } + + async onSourceControlUserPulledAPI(data: { + workflow_updates: number; + forced: boolean; + }): Promise { + return this.telemetry.track('User pulled via API', data); + } + + async onSourceControlUserStartedPushUI(data: { + workflows_eligible: number; + workflows_eligible_with_conflicts: number; + creds_eligible: number; + creds_eligible_with_conflicts: number; + variables_eligible: number; + }): Promise { + return this.telemetry.track('User started push via UI', data); + } + + async onSourceControlUserFinishedPushUI(data: { + workflows_eligible: number; + workflows_pushed: number; + creds_pushed: number; + variables_pushed: number; + }): Promise { + return this.telemetry.track('User finished push via UI', data); + } } diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 664d730081cf4..ee94b22ce0b6c 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -217,7 +217,7 @@ export const handleLdapInit = async (): Promise => { try { await setGlobalLdapConfigVariables(ldapConfig); } catch (error) { - Logger.error( + Logger.warn( `Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument error, diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index 5353a8fdb6280..e0d06b93a880c 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -6,7 +6,11 @@ import { authorize } from '../../shared/middlewares/global.middleware'; import type { ImportResult } from '@/environments/sourceControl/types/importResult'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; -import { isSourceControlLicensed } from '@/environments/sourceControl/sourceControlHelper.ee'; +import { + getTrackingInformationFromPullResult, + isSourceControlLicensed, +} from '@/environments/sourceControl/sourceControlHelper.ee'; +import { InternalHooks } from '@/InternalHooks'; export = { pull: [ @@ -32,12 +36,16 @@ export = { force: req.body.force, variables: req.body.variables, userId: req.user.id, - importAfterPull: true, }); - if ((result as ImportResult)?.workflows) { - return res.status(200).send(result as ImportResult); + + if (result.statusCode === 200) { + void Container.get(InternalHooks).onSourceControlUserPulledAPI({ + ...getTrackingInformationFromPullResult(result.statusResult), + forced: req.body.force ?? false, + }); + return res.status(200).send(result.statusResult); } else { - return res.status(409).send(result); + return res.status(409).send(result.statusResult); } } catch (error) { return res.status(400).send((error as { message: string }).message); diff --git a/packages/cli/src/environments/sourceControl/constants.ts b/packages/cli/src/environments/sourceControl/constants.ts index 3ef023f3498da..5b88d858b739c 100644 --- a/packages/cli/src/environments/sourceControl/constants.ts +++ b/packages/cli/src/environments/sourceControl/constants.ts @@ -5,7 +5,7 @@ export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows'; export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs'; export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json'; export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json'; -export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'owners.json'; +export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'workflow_owners.json'; export const SOURCE_CONTROL_SSH_FOLDER = 'ssh'; export const SOURCE_CONTROL_SSH_KEY_NAME = 'key'; export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main'; @@ -14,3 +14,5 @@ export const SOURCE_CONTROL_API_ROOT = 'source-control'; export const SOURCE_CONTROL_README = ` # n8n Source Control `; +export const SOURCE_CONTROL_DEFAULT_NAME = 'n8n user'; +export const SOURCE_CONTROL_DEFAULT_EMAIL = 'n8n@example.com'; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts index 7c0497d4a6e62..b932673011585 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -1,6 +1,3 @@ -import express from 'express'; -import { Service } from 'typedi'; -import type { PullResult, PushResult, StatusResult } from 'simple-git'; import { Authorized, Get, Post, Patch, RestController } from '@/decorators'; import { sourceControlLicensedMiddleware, @@ -8,12 +5,18 @@ import { } from './middleware/sourceControlEnabledMiddleware.ee'; import { SourceControlService } from './sourceControl.service.ee'; import { SourceControlRequest } from './types/requests'; -import type { SourceControlPreferences } from './types/sourceControlPreferences'; -import { BadRequestError } from '@/ResponseHelper'; -import type { ImportResult } from './types/importResult'; import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee'; +import type { SourceControlPreferences } from './types/sourceControlPreferences'; import type { SourceControlledFile } from './types/sourceControlledFile'; import { SOURCE_CONTROL_API_ROOT, SOURCE_CONTROL_DEFAULT_BRANCH } from './constants'; +import { BadRequestError } from '@/ResponseHelper'; +import type { PullResult } from 'simple-git'; +import express from 'express'; +import type { ImportResult } from './types/importResult'; +import Container, { Service } from 'typedi'; +import { InternalHooks } from '../../InternalHooks'; +import { getRepoType } from './sourceControlHelper.ee'; +import { SourceControlGetStatus } from './types/sourceControlGetStatus'; @Service() @RestController(`/${SOURCE_CONTROL_API_ROOT}`) @@ -23,7 +26,7 @@ export class SourceControlController { private sourceControlPreferencesService: SourceControlPreferencesService, ) {} - @Authorized('any') + @Authorized('none') @Get('/preferences', { middlewares: [sourceControlLicensedMiddleware] }) async getPreferences(): Promise { // returns the settings with the privateKey property redacted @@ -56,14 +59,17 @@ export class SourceControlController { ); if (sanitizedPreferences.initRepo === true) { try { - await this.sourceControlService.initializeRepository({ - ...updatedPreferences, - branchName: - updatedPreferences.branchName === '' - ? SOURCE_CONTROL_DEFAULT_BRANCH - : updatedPreferences.branchName, - initRepo: true, - }); + await this.sourceControlService.initializeRepository( + { + ...updatedPreferences, + branchName: + updatedPreferences.branchName === '' + ? SOURCE_CONTROL_DEFAULT_BRANCH + : updatedPreferences.branchName, + initRepo: true, + }, + req.user, + ); if (this.sourceControlPreferencesService.getPreferences().branchName !== '') { await this.sourceControlPreferencesService.setPreferences({ connected: true, @@ -76,7 +82,17 @@ export class SourceControlController { } } await this.sourceControlService.init(); - return this.sourceControlPreferencesService.getPreferences(); + const resultingPreferences = this.sourceControlPreferencesService.getPreferences(); + // #region Tracking Information + // located in controller so as to not call this multiple times when updating preferences + void Container.get(InternalHooks).onSourceControlSettingsUpdated({ + branch_name: resultingPreferences.branchName, + connected: resultingPreferences.connected, + read_only_instance: resultingPreferences.branchReadOnly, + repo_type: getRepoType(resultingPreferences.repositoryUrl), + }); + // #endregion + return resultingPreferences; } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -92,8 +108,6 @@ export class SourceControlController { connected: undefined, publicKey: undefined, repositoryUrl: undefined, - authorName: undefined, - authorEmail: undefined, }; const currentPreferences = this.sourceControlPreferencesService.getPreferences(); await this.sourceControlPreferencesService.validateSourceControlPreferences( @@ -115,7 +129,14 @@ export class SourceControlController { ); } await this.sourceControlService.init(); - return this.sourceControlPreferencesService.getPreferences(); + const resultingPreferences = this.sourceControlPreferencesService.getPreferences(); + void Container.get(InternalHooks).onSourceControlSettingsUpdated({ + branch_name: resultingPreferences.branchName, + connected: resultingPreferences.connected, + read_only_instance: resultingPreferences.branchReadOnly, + repo_type: getRepoType(resultingPreferences.repositoryUrl), + }); + return resultingPreferences; } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -146,18 +167,18 @@ export class SourceControlController { async pushWorkfolder( req: SourceControlRequest.PushWorkFolder, res: express.Response, - ): Promise { + ): Promise { 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); - if ((result as PushResult).pushed) { - res.statusCode = 200; - } else { - res.statusCode = 409; - } - return result; + res.statusCode = result.statusCode; + return result.statusResult; } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -168,20 +189,15 @@ export class SourceControlController { async pullWorkfolder( req: SourceControlRequest.PullWorkFolder, res: express.Response, - ): Promise { + ): Promise { try { const result = await this.sourceControlService.pullWorkfolder({ force: req.body.force, variables: req.body.variables, userId: req.user.id, - importAfterPull: req.body.importAfterPull ?? true, }); - if ((result as ImportResult)?.workflows) { - res.statusCode = 200; - } else { - res.statusCode = 409; - } - return result; + res.statusCode = result.statusCode; + return result.statusResult; } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -189,16 +205,9 @@ export class SourceControlController { @Authorized(['global', 'owner']) @Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) - async resetWorkfolder( - req: SourceControlRequest.PullWorkFolder, - ): Promise { + async resetWorkfolder(): Promise { try { - return await this.sourceControlService.resetWorkfolder({ - force: req.body.force, - variables: req.body.variables, - userId: req.user.id, - importAfterPull: req.body.importAfterPull ?? true, - }); + return await this.sourceControlService.resetWorkfolder(); } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -206,9 +215,12 @@ export class SourceControlController { @Authorized('any') @Get('/get-status', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) - async getStatus() { + async getStatus(req: SourceControlRequest.GetStatus) { try { - return await this.sourceControlService.getStatus(); + const result = (await this.sourceControlService.getStatus( + new SourceControlGetStatus(req.query), + )) as SourceControlledFile[]; + return result; } catch (error) { throw new BadRequestError((error as { message: string }).message); } @@ -216,9 +228,9 @@ export class SourceControlController { @Authorized('any') @Get('/status', { middlewares: [sourceControlLicensedMiddleware] }) - async status(): Promise { + async status(req: SourceControlRequest.GetStatus) { try { - return await this.sourceControlService.status(); + return await this.sourceControlService.getStatus(new SourceControlGetStatus(req.query)); } catch (error) { throw new BadRequestError((error as { message: string }).message); } diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index 8dea0e077188a..f3cc7a833b9e9 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -1,40 +1,44 @@ -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import path from 'path'; import * as Db from '@/Db'; -import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee'; +import { + getTagsPath, + getTrackingInformationFromPostPushResult, + getTrackingInformationFromPrePushResult, + getTrackingInformationFromPullResult, + getVariablesPath, + sourceControlFoldersExistCheck, +} from './sourceControlHelper.ee'; import type { SourceControlPreferences } from './types/sourceControlPreferences'; import { - SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, + SOURCE_CONTROL_DEFAULT_EMAIL, + SOURCE_CONTROL_DEFAULT_NAME, SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_README, SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_SSH_KEY_NAME, - SOURCE_CONTROL_TAGS_EXPORT_FILE, - SOURCE_CONTROL_VARIABLES_EXPORT_FILE, - SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, } from './constants'; import { LoggerProxy } from 'n8n-workflow'; import { SourceControlGitService } from './sourceControlGit.service.ee'; import { UserSettings } from 'n8n-core'; -import type { PushResult, StatusResult } from 'simple-git'; -import type { ExportResult } from './types/exportResult'; +import type { PushResult } from 'simple-git'; import { SourceControlExportService } from './sourceControlExport.service.ee'; -import { BadRequestError } from '../../ResponseHelper'; +import { BadRequestError } from '@/ResponseHelper'; import type { ImportResult } from './types/importResult'; import type { SourceControlPushWorkFolder } from './types/sourceControlPushWorkFolder'; import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder'; -import type { - SourceControlledFileLocation, - SourceControlledFile, - SourceControlledFileStatus, - SourceControlledFileType, -} from './types/sourceControlledFile'; +import type { SourceControlledFile } from './types/sourceControlledFile'; import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee'; import { writeFileSync } from 'fs'; import { SourceControlImportService } from './sourceControlImport.service.ee'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; - +import type { User } from '@db/entities/User'; +import isEqual from 'lodash/isEqual'; +import type { SourceControlGetStatus } from './types/sourceControlGetStatus'; +import type { TagEntity } from '@db/entities/TagEntity'; +import type { Variables } from '@db/entities/Variables'; +import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId'; +import type { ExportableCredential } from './types/exportableCredential'; +import { InternalHooks } from '@/InternalHooks'; @Service() export class SourceControlService { private sshKeyName: string; @@ -59,6 +63,12 @@ export class SourceControlService { this.gitService.resetService(); sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); await this.sourceControlPreferencesService.loadFromDbAndApplySourceControlPreferences(); + if (this.sourceControlPreferencesService.isSourceControlLicensedAndEnabled()) { + await this.initGitService(); + } + } + + private async initGitService(): Promise { await this.gitService.initService({ sourceControlPreferences: this.sourceControlPreferencesService.getPreferences(), gitFolder: this.gitFolder, @@ -67,6 +77,33 @@ export class SourceControlService { }); } + private async sanityCheck(): Promise { + try { + const foldersExisted = sourceControlFoldersExistCheck( + [this.gitFolder, this.sshFolder], + false, + ); + if (!foldersExisted) { + throw new Error(); + } + if (!this.gitService.git) { + await this.initGitService(); + } + const branches = await this.gitService.getCurrentBranch(); + if ( + branches.current === '' || + branches.current !== + this.sourceControlPreferencesService.sourceControlPreferences.branchName + ) { + throw new Error(); + } + } catch (error) { + throw new BadRequestError( + 'Source control is not properly set up, please disconnect and reconnect.', + ); + } + } + async disconnect(options: { keepKeyPair?: boolean } = {}) { try { await this.sourceControlPreferencesService.setPreferences({ @@ -84,12 +121,12 @@ export class SourceControlService { } } - async initializeRepository(preferences: SourceControlPreferences) { + async initializeRepository(preferences: SourceControlPreferences, user: User) { if (!this.gitService.git) { - await this.init(); + await this.initGitService(); } LoggerProxy.debug('Initializing repository...'); - await this.gitService.initRepository(preferences); + await this.gitService.initRepository(preferences, user); let getBranchesResult; try { getBranchesResult = await this.getBranches(); @@ -115,59 +152,32 @@ export class SourceControlService { force: true, }); getBranchesResult = await this.getBranches(); + await this.gitService.setBranch(preferences.branchName); } catch (fileError) { LoggerProxy.error(`Failed to create initial commit: ${(fileError as Error).message}`); } - } else { - await this.sourceControlPreferencesService.setPreferences({ - branchName: '', - connected: true, - }); } } + await this.sourceControlPreferencesService.setPreferences({ + branchName: getBranchesResult.currentBranch, + connected: true, + }); return getBranchesResult; } - async export() { - const result: { - tags: ExportResult | undefined; - credentials: ExportResult | undefined; - variables: ExportResult | undefined; - workflows: ExportResult | undefined; - } = { - credentials: undefined, - tags: undefined, - variables: undefined, - workflows: undefined, - }; - try { - // comment next line if needed - await this.sourceControlExportService.cleanWorkFolder(); - result.tags = await this.sourceControlExportService.exportTagsToWorkFolder(); - result.variables = await this.sourceControlExportService.exportVariablesToWorkFolder(); - result.workflows = await this.sourceControlExportService.exportWorkflowsToWorkFolder(); - result.credentials = await this.sourceControlExportService.exportCredentialsToWorkFolder(); - } catch (error) { - throw new BadRequestError((error as { message: string }).message); - } - return result; - } - - async import(options: SourceControllPullOptions): Promise { - try { - return await this.sourceControlImportService.importFromWorkFolder(options); - } catch (error) { - throw new BadRequestError((error as { message: string }).message); - } - } - async getBranches(): Promise<{ branches: string[]; currentBranch: string }> { // fetch first to get include remote changes + if (!this.gitService.git) { + await this.initGitService(); + } await this.gitService.fetch(); return this.gitService.getBranches(); } async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> { + if (!this.gitService.git) { + await this.initGitService(); + } await this.sourceControlPreferencesService.setPreferences({ branchName: branch, connected: branch?.length > 0, @@ -177,282 +187,584 @@ export class SourceControlService { // will reset the branch to the remote branch and pull // this will discard all local changes - async resetWorkfolder(options: SourceControllPullOptions): Promise { - const currentBranch = await this.gitService.getCurrentBranch(); - await this.sourceControlExportService.cleanWorkFolder(); - await this.gitService.resetBranch({ - hard: true, - target: currentBranch.remote, - }); - await this.gitService.pull(); - if (options.importAfterPull) { - return this.import(options); + async resetWorkfolder(): Promise { + if (!this.gitService.git) { + await this.initGitService(); + } + try { + await this.gitService.resetBranch(); + await this.gitService.pull(); + } catch (error) { + LoggerProxy.error(`Failed to reset workfolder: ${(error as Error).message}`); + throw new Error( + 'Unable to fetch updates from git - your folder might be out of sync. Try reconnecting from the Source Control settings page.', + ); } return; } - async pushWorkfolder( - options: SourceControlPushWorkFolder, - ): Promise { + async pushWorkfolder(options: SourceControlPushWorkFolder): Promise<{ + statusCode: number; + pushResult: PushResult | undefined; + statusResult: SourceControlledFile[]; + }> { + await this.sanityCheck(); + if (this.sourceControlPreferencesService.isBranchReadOnly()) { throw new BadRequestError('Cannot push onto read-only branch.'); } - if (!options.skipDiff) { - const diffResult = await this.getStatus(); - const possibleConflicts = diffResult?.filter((file) => file.conflict); - if (possibleConflicts?.length > 0 && options.force !== true) { - await this.unstage(); - return diffResult; + + // only determine file status if not provided by the frontend + let statusResult: SourceControlledFile[] = options.fileNames; + if (statusResult.length === 0) { + statusResult = (await this.getStatus({ + direction: 'push', + verbose: false, + preferLocalVersion: true, + })) as SourceControlledFile[]; + } + + if (!options.force) { + const possibleConflicts = statusResult?.filter((file) => file.conflict); + if (possibleConflicts?.length > 0) { + return { + statusCode: 409, + pushResult: undefined, + statusResult, + }; + } + } + + const filesToBePushed = new Set(); + const filesToBeDeleted = new Set(); + options.fileNames.forEach((e) => { + if (e.status !== 'deleted') { + filesToBePushed.add(e.file); + } else { + filesToBeDeleted.add(e.file); + } + }); + + this.sourceControlExportService.rmFilesFromExportFolder(filesToBeDeleted); + + const workflowsToBeExported = options.fileNames.filter( + (e) => e.type === 'workflow' && e.status !== 'deleted', + ); + await this.sourceControlExportService.exportWorkflowsToWorkFolder(workflowsToBeExported); + + const credentialsToBeExported = options.fileNames.filter( + (e) => e.type === 'credential' && e.status !== 'deleted', + ); + const credentialExportResult = + await this.sourceControlExportService.exportCredentialsToWorkFolder(credentialsToBeExported); + if (credentialExportResult.missingIds && credentialExportResult.missingIds.length > 0) { + credentialExportResult.missingIds.forEach((id) => { + filesToBePushed.delete(this.sourceControlExportService.getCredentialsPath(id)); + statusResult = statusResult.filter( + (e) => e.file !== this.sourceControlExportService.getCredentialsPath(id), + ); + }); + } + + if (options.fileNames.find((e) => e.type === 'tags')) { + await this.sourceControlExportService.exportTagsToWorkFolder(); + } + + if (options.fileNames.find((e) => e.type === 'variables')) { + await this.sourceControlExportService.exportVariablesToWorkFolder(); + } + + await this.gitService.stage(filesToBePushed, filesToBeDeleted); + + for (let i = 0; i < statusResult.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + if (options.fileNames.find((file) => file.file === statusResult[i].file)) { + statusResult[i].pushed = true; } } - await this.unstage(); - await this.stage(options); + await this.gitService.commit(options.message ?? 'Updated Workfolder'); - return this.gitService.push({ + + const pushResult = await this.gitService.push({ branch: this.sourceControlPreferencesService.getBranchName(), force: options.force ?? false, }); + + // #region Tracking Information + void Container.get(InternalHooks).onSourceControlUserFinishedPushUI( + getTrackingInformationFromPostPushResult(statusResult), + ); + // #endregion + + return { + statusCode: 200, + pushResult, + statusResult, + }; } async pullWorkfolder( options: SourceControllPullOptions, - ): Promise { - await this.resetWorkfolder({ - importAfterPull: false, - userId: options.userId, - force: false, + ): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> { + await this.sanityCheck(); + + const statusResult = (await this.getStatus({ + direction: 'pull', + verbose: false, + preferLocalVersion: false, + })) as SourceControlledFile[]; + + // filter out items that will not effect a local change and thus should not + // trigger a conflict warning in the frontend + const filteredResult = statusResult.filter((e) => { + // locally created credentials will not create a conflict on pull + if (e.status === 'created' && e.location === 'local') { + return false; + } + // remotely deleted credentials will not delete local credentials + if (e.type === 'credential' && e.status === 'deleted') { + return false; + } + return true; }); - await this.export(); // refresh workfolder - const status = await this.gitService.status(); - if (status.modified.length > 0 && options.force !== true) { - return status; - } - await this.resetWorkfolder({ ...options, importAfterPull: false }); - if (options.importAfterPull) { - return this.import(options); + if (options.force !== true) { + const possibleConflicts = filteredResult?.filter( + (file) => (file.conflict || file.status === 'modified') && file.type === 'workflow', + ); + if (possibleConflicts?.length > 0) { + await this.gitService.resetBranch(); + return { + statusCode: 409, + statusResult: filteredResult, + }; + } } - return; - } - async stage( - options: Pick, - ): Promise<{ staged: string[] } | string> { - const { fileNames, credentialIds, workflowIds } = options; - const status = await this.gitService.status(); - let mergedFileNames = new Set(); - fileNames?.forEach((e) => mergedFileNames.add(e)); - credentialIds?.forEach((e) => - mergedFileNames.add(this.sourceControlExportService.getCredentialsPath(e)), + const workflowsToBeImported = statusResult.filter( + (e) => e.type === 'workflow' && e.status !== 'deleted', + ); + await this.sourceControlImportService.importWorkflowFromWorkFolder( + workflowsToBeImported, + options.userId, + ); + + const credentialsToBeImported = statusResult.filter( + (e) => e.type === 'credential' && e.status !== 'deleted', ); - workflowIds?.forEach((e) => - mergedFileNames.add(this.sourceControlExportService.getWorkflowPath(e)), + await this.sourceControlImportService.importCredentialsFromWorkFolder( + credentialsToBeImported, + options.userId, ); - if (mergedFileNames.size === 0) { - mergedFileNames = new Set([ - ...status.not_added, - ...status.created, - ...status.modified, - ]); + + const tagsToBeImported = statusResult.find((e) => e.type === 'tags'); + if (tagsToBeImported) { + await this.sourceControlImportService.importTagsFromWorkFolder(tagsToBeImported); } - mergedFileNames.add(this.sourceControlExportService.getOwnersPath()); - const deletedFiles = new Set(status.deleted); - deletedFiles.forEach((e) => mergedFileNames.delete(e)); - await this.unstage(); - const stageResult = await this.gitService.stage(mergedFileNames, deletedFiles); - if (!stageResult) { - const statusResult = await this.gitService.status(); - return { staged: statusResult.staged }; + + const variablesToBeImported = statusResult.find((e) => e.type === 'variables'); + if (variablesToBeImported) { + await this.sourceControlImportService.importVariablesFromWorkFolder(variablesToBeImported); } - return stageResult; + + // #region Tracking Information + void Container.get(InternalHooks).onSourceControlUserFinishedPullUI( + getTrackingInformationFromPullResult(statusResult), + ); + // #endregion + + return { + statusCode: 200, + statusResult: filteredResult, + }; } - async unstage(): Promise { - const stageResult = await this.gitService.resetBranch(); - if (!stageResult) { - return this.gitService.status(); + /** + * Does a comparison between the local and remote workfolder based on NOT the git status, + * but certain parameters within the items being synced. + * For workflows, it compares the versionIds + * For credentials, it compares the name, type and nodeAccess + * For variables, it compares the name + * For tags, it compares the name and mapping + * @returns either SourceControlledFile[] if verbose is false, + * or multiple SourceControlledFile[] with all determined differences for debugging purposes + */ + async getStatus(options: SourceControlGetStatus) { + await this.sanityCheck(); + + const sourceControlledFiles: SourceControlledFile[] = []; + + // fetch and reset hard first + await this.resetWorkfolder(); + + const { + wfRemoteVersionIds, + wfLocalVersionIds, + wfMissingInLocal, + wfMissingInRemote, + wfModifiedInEither, + } = await this.getStatusWorkflows(options, sourceControlledFiles); + + const { credMissingInLocal, credMissingInRemote, credModifiedInEither } = + await this.getStatusCredentials(options, sourceControlledFiles); + + const { varMissingInLocal, varMissingInRemote, varModifiedInEither } = + await this.getStatusVariables(options, sourceControlledFiles); + + const { + tagsMissingInLocal, + tagsMissingInRemote, + tagsModifiedInEither, + mappingsMissingInLocal, + mappingsMissingInRemote, + } = await this.getStatusTagsMappings(options, sourceControlledFiles); + + // #region Tracking Information + if (options.direction === 'push') { + void Container.get(InternalHooks).onSourceControlUserStartedPushUI( + getTrackingInformationFromPrePushResult(sourceControlledFiles), + ); + } else if (options.direction === 'pull') { + void Container.get(InternalHooks).onSourceControlUserStartedPullUI( + getTrackingInformationFromPullResult(sourceControlledFiles), + ); } - return stageResult; - } + // #endregion - async status(): Promise { - return this.gitService.status(); + if (options?.verbose) { + return { + wfRemoteVersionIds, + wfLocalVersionIds, + wfMissingInLocal, + wfMissingInRemote, + wfModifiedInEither, + credMissingInLocal, + credMissingInRemote, + credModifiedInEither, + varMissingInLocal, + varMissingInRemote, + varModifiedInEither, + tagsMissingInLocal, + tagsMissingInRemote, + tagsModifiedInEither, + mappingsMissingInLocal, + mappingsMissingInRemote, + sourceControlledFiles, + }; + } else { + return sourceControlledFiles; + } } - private async fileNameToSourceControlledFile( - fileName: string, - location: SourceControlledFileLocation, - statusResult: StatusResult, - ): Promise { - let id: string | undefined = undefined; - let name = ''; - let conflict = false; - let status: SourceControlledFileStatus = 'unknown'; - let type: SourceControlledFileType = 'file'; - let updatedAt = ''; - - const allWorkflows: Map = new Map(); - (await Db.collections.Workflow.find({ select: ['id', 'name', 'updatedAt'] })).forEach( - (workflow) => { - allWorkflows.set(workflow.id, workflow); - }, + private async getStatusWorkflows( + options: SourceControlGetStatus, + sourceControlledFiles: SourceControlledFile[], + ) { + const wfRemoteVersionIds = await this.sourceControlImportService.getRemoteVersionIdsFromFiles(); + const wfLocalVersionIds = await this.sourceControlImportService.getLocalVersionIdsFromDb(); + + const wfMissingInLocal = wfRemoteVersionIds.filter( + (remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1, ); - const allCredentials: Map = new Map(); - (await Db.collections.Credentials.find({ select: ['id', 'name', 'updatedAt'] })).forEach( - (credential) => { - allCredentials.set(credential.id, credential); - }, + + const wfMissingInRemote = wfLocalVersionIds.filter( + (local) => wfRemoteVersionIds.findIndex((remote) => remote.id === local.id) === -1, ); - // initialize status from git status result - if (statusResult.not_added.find((e) => e === fileName)) status = 'new'; - else if (statusResult.conflicted.find((e) => e === fileName)) { - status = 'conflicted'; - conflict = true; - } else if (statusResult.created.find((e) => e === fileName)) status = 'created'; - else if (statusResult.deleted.find((e) => e === fileName)) status = 'deleted'; - else if (statusResult.modified.find((e) => e === fileName)) status = 'modified'; - - if (fileName.startsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER)) { - type = 'workflow'; - if (status === 'deleted') { - id = fileName - .replace(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, '') - .replace(/[\/,\\]/, '') - .replace('.json', ''); - if (location === 'remote') { - const existingWorkflow = allWorkflows.get(id); - if (existingWorkflow) { - name = existingWorkflow.name; - updatedAt = existingWorkflow.updatedAt.toISOString(); - } - } else { - name = '(deleted)'; - // todo: once we have audit log, this deletion date could be looked up - } - } else { - const workflow = await this.sourceControlExportService.getWorkflowFromFile(fileName); - if (!workflow?.id) { - if (location === 'local') { - return; - } - id = fileName - .replace(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER + '/', '') - .replace('.json', ''); - status = 'created'; - } else { - id = workflow.id; - name = workflow.name; - } - const existingWorkflow = allWorkflows.get(id); - if (existingWorkflow) { - name = existingWorkflow.name; - updatedAt = existingWorkflow.updatedAt.toISOString(); - } + const wfModifiedInEither: SourceControlWorkflowVersionId[] = []; + wfLocalVersionIds.forEach((local) => { + const mismatchingIds = wfRemoteVersionIds.find( + (remote) => remote.id === local.id && remote.versionId !== local.versionId, + ); + let name = (options?.preferLocalVersion ? local?.name : mismatchingIds?.name) ?? 'Workflow'; + if (local.name && mismatchingIds?.name && local.name !== mismatchingIds.name) { + name = options?.preferLocalVersion + ? `${local.name} (Remote: ${mismatchingIds.name})` + : (name = `${mismatchingIds.name} (Local: ${local.name})`); } - } - if (fileName.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) { - type = 'credential'; - if (status === 'deleted') { - id = fileName - .replace(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, '') - .replace(/[\/,\\]/, '') - .replace('.json', ''); - if (location === 'remote') { - const existingCredential = allCredentials.get(id); - if (existingCredential) { - name = existingCredential.name; - updatedAt = existingCredential.updatedAt.toISOString(); - } - } else { - name = '(deleted)'; - } - } else { - const credential = await this.sourceControlExportService.getCredentialFromFile(fileName); - if (!credential?.id) { - if (location === 'local') { - return; - } - id = fileName - .replace(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER + '/', '') - .replace('.json', ''); - status = 'created'; - } else { - id = credential.id; - name = credential.name; - } - const existingCredential = allCredentials.get(id); - if (existingCredential) { - name = existingCredential.name; - updatedAt = existingCredential.updatedAt.toISOString(); - } + if (mismatchingIds) { + wfModifiedInEither.push({ + ...local, + name, + versionId: options.preferLocalVersion ? local.versionId : mismatchingIds.versionId, + localId: local.versionId, + remoteId: mismatchingIds.versionId, + }); } - } + }); - if (fileName.startsWith(SOURCE_CONTROL_VARIABLES_EXPORT_FILE)) { - id = 'variables'; - name = 'variables'; - type = 'variables'; - } + wfMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Workflow', + type: 'workflow', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: item.filename, + updatedAt: item.updatedAt ?? new Date().toISOString(), + }); + }); - if (fileName.startsWith(SOURCE_CONTROL_TAGS_EXPORT_FILE)) { - const lastUpdatedTag = await Db.collections.Tag.find({ - order: { updatedAt: 'DESC' }, - take: 1, - select: ['updatedAt'], + wfMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Workflow', + type: 'workflow', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: item.filename, + updatedAt: item.updatedAt ?? new Date().toISOString(), }); - id = 'tags'; - name = 'tags'; - type = 'tags'; - updatedAt = lastUpdatedTag[0]?.updatedAt.toISOString(); - } + }); - if (!id) return; + wfModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Workflow', + type: 'workflow', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: item.filename, + updatedAt: item.updatedAt ?? new Date().toISOString(), + }); + }); return { - file: fileName, - id, - name, - type, - status, - location, - conflict, - updatedAt, + wfRemoteVersionIds, + wfLocalVersionIds, + wfMissingInLocal, + wfMissingInRemote, + wfModifiedInEither, }; } - async getStatus(): Promise { - await this.export(); - await this.stage({}); - await this.gitService.fetch(); - const sourceControlledFiles: SourceControlledFile[] = []; - const diffRemote = await this.gitService.diffRemote(); - const diffLocal = await this.gitService.diffLocal(); - const status = await this.gitService.status(); - await Promise.all([ - ...(diffRemote?.files.map(async (e) => { - const resolvedFile = await this.fileNameToSourceControlledFile(e.file, 'remote', status); - if (resolvedFile) { - sourceControlledFiles.push(resolvedFile); - } - }) ?? []), - ...(diffLocal?.files.map(async (e) => { - const resolvedFile = await this.fileNameToSourceControlledFile(e.file, 'local', status); - if (resolvedFile) { - sourceControlledFiles.push(resolvedFile); - } - }) ?? []), - ]); - sourceControlledFiles.forEach((e, index, array) => { - const similarItems = array.filter( - (f) => f.type === e.type && (f.file === e.file || f.id === e.id), + private async getStatusCredentials( + options: SourceControlGetStatus, + sourceControlledFiles: SourceControlledFile[], + ) { + const credRemoteIds = await this.sourceControlImportService.getRemoteCredentialsFromFiles(); + const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(); + + const credMissingInLocal = credRemoteIds.filter( + (remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1, + ); + + const credMissingInRemote = credLocalIds.filter( + (local) => credRemoteIds.findIndex((remote) => remote.id === local.id) === -1, + ); + + // only compares the name, since that is the only change synced for credentials + const credModifiedInEither: Array< + ExportableCredential & { + filename: string; + } + > = []; + credLocalIds.forEach((local) => { + const mismatchingCreds = credRemoteIds.find((remote) => { + return ( + remote.id === local.id && + (remote.name !== local.name || + remote.type !== local.type || + !isEqual(remote.nodesAccess, local.nodesAccess)) + ); + }); + if (mismatchingCreds) { + credModifiedInEither.push({ + ...local, + name: options?.preferLocalVersion ? local.name : mismatchingCreds.name, + }); + } + }); + + credMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Credential', + type: 'credential', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: item.filename, + updatedAt: new Date().toISOString(), + }); + }); + + credMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Credential', + type: 'credential', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: item.filename, + updatedAt: new Date().toISOString(), + }); + }); + + credModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name ?? 'Credential', + type: 'credential', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: item.filename, + updatedAt: new Date().toISOString(), + }); + }); + return { + credMissingInLocal, + credMissingInRemote, + credModifiedInEither, + }; + } + + private async getStatusVariables( + options: SourceControlGetStatus, + sourceControlledFiles: SourceControlledFile[], + ) { + const varRemoteIds = await this.sourceControlImportService.getRemoteVariablesFromFile(); + const varLocalIds = await this.sourceControlImportService.getLocalVariablesFromDb(); + + const varMissingInLocal = varRemoteIds.filter( + (remote) => varLocalIds.findIndex((local) => local.id === remote.id) === -1, + ); + + const varMissingInRemote = varLocalIds.filter( + (local) => varRemoteIds.findIndex((remote) => remote.id === local.id) === -1, + ); + + const varModifiedInEither: Variables[] = []; + varLocalIds.forEach((local) => { + const mismatchingIds = varRemoteIds.find( + (remote) => + (remote.id === local.id && remote.key !== local.key) || + (remote.id !== local.id && remote.key === local.key), ); - if (similarItems.length > 1) { - similarItems.forEach((item) => { - item.conflict = true; + if (mismatchingIds) { + varModifiedInEither.push(options.preferLocalVersion ? local : mismatchingIds); + } + }); + + if ( + varMissingInLocal.length > 0 || + varMissingInRemote.length > 0 || + varModifiedInEither.length > 0 + ) { + if (options.direction === 'pull' && varRemoteIds.length === 0) { + // if there's nothing to pull, don't show difference as modified + } else { + sourceControlledFiles.push({ + id: 'variables', + name: 'variables', + type: 'variables', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), }); } + } + return { + varMissingInLocal, + varMissingInRemote, + varModifiedInEither, + }; + } + + private async getStatusTagsMappings( + options: SourceControlGetStatus, + sourceControlledFiles: SourceControlledFile[], + ) { + const lastUpdatedTag = await Db.collections.Tag.find({ + order: { updatedAt: 'DESC' }, + take: 1, + select: ['updatedAt'], + }); + + const tagMappingsRemote = + await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(); + const tagMappingsLocal = await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(); + + const tagsMissingInLocal = tagMappingsRemote.tags.filter( + (remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1, + ); + + const tagsMissingInRemote = tagMappingsLocal.tags.filter( + (local) => tagMappingsRemote.tags.findIndex((remote) => remote.id === local.id) === -1, + ); + + const tagsModifiedInEither: TagEntity[] = []; + tagMappingsLocal.tags.forEach((local) => { + const mismatchingIds = tagMappingsRemote.tags.find( + (remote) => remote.id === local.id && remote.name !== local.name, + ); + if (!mismatchingIds) { + return; + } + tagsModifiedInEither.push(options.preferLocalVersion ? local : mismatchingIds); }); - return sourceControlledFiles; + + const mappingsMissingInLocal = tagMappingsRemote.mappings.filter( + (remote) => + tagMappingsLocal.mappings.findIndex( + (local) => local.tagId === remote.tagId && local.workflowId === remote.workflowId, + ) === -1, + ); + + const mappingsMissingInRemote = tagMappingsLocal.mappings.filter( + (local) => + tagMappingsRemote.mappings.findIndex( + (remote) => remote.tagId === local.tagId && remote.workflowId === remote.workflowId, + ) === -1, + ); + + if ( + tagsMissingInLocal.length > 0 || + tagsMissingInRemote.length > 0 || + tagsModifiedInEither.length > 0 || + mappingsMissingInLocal.length > 0 || + mappingsMissingInRemote.length > 0 + ) { + if ( + options.direction === 'pull' && + tagMappingsRemote.tags.length === 0 && + tagMappingsRemote.mappings.length === 0 + ) { + // if there's nothing to pull, don't show difference as modified + } else { + sourceControlledFiles.push({ + id: 'mappings', + name: 'tags', + type: 'tags', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + } + } + return { + tagsMissingInLocal, + tagsMissingInRemote, + tagsModifiedInEither, + mappingsMissingInLocal, + mappingsMissingInRemote, + }; + } + + async setGitUserDetails( + name = SOURCE_CONTROL_DEFAULT_NAME, + email = SOURCE_CONTROL_DEFAULT_EMAIL, + ): Promise { + await this.sanityCheck(); + await this.gitService.setGitUserDetails(name, email); } } diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index 12806dd67aefe..72715772b95bd 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -3,23 +3,28 @@ import path from 'path'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, - SOURCE_CONTROL_OWNERS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE, - SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, } from './constants'; import * as Db from '@/Db'; -import glob from 'fast-glob'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import { LoggerProxy, jsonParse } from 'n8n-workflow'; -import { writeFile as fsWriteFile, readFile as fsReadFile, rm as fsRm } from 'fs/promises'; +import { LoggerProxy } from 'n8n-workflow'; +import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises'; +import { rmSync } from 'fs'; import { Credentials, UserSettings } from 'n8n-core'; -import type { IWorkflowToImport } from '@/Interfaces'; import type { ExportableWorkflow } from './types/exportableWorkflow'; import type { ExportableCredential } from './types/exportableCredential'; import type { ExportResult } from './types/exportResult'; -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee'; +import { + getCredentialExportPath, + getVariablesPath, + getWorkflowExportPath, + sourceControlFoldersExistCheck, + stringContainsExpression, +} from './sourceControlHelper.ee'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { In } from 'typeorm'; +import type { SourceControlledFile } from './types/sourceControlledFile'; @Service() export class SourceControlExportService { @@ -40,79 +45,11 @@ export class SourceControlExportService { } getWorkflowPath(workflowId: string): string { - return path.join(this.workflowExportFolder, `${workflowId}.json`); + return getWorkflowExportPath(workflowId, this.workflowExportFolder); } getCredentialsPath(credentialsId: string): string { - return path.join(this.credentialExportFolder, `${credentialsId}.json`); - } - - getTagsPath(): string { - return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); - } - - getOwnersPath(): string { - return path.join(this.gitFolder, SOURCE_CONTROL_OWNERS_EXPORT_FILE); - } - - getVariablesPath(): string { - return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE); - } - - async getWorkflowFromFile( - filePath: string, - root = this.gitFolder, - ): Promise { - try { - const importedWorkflow = jsonParse( - await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }), - ); - return importedWorkflow; - } catch (error) { - return undefined; - } - } - - async getCredentialFromFile( - filePath: string, - root = this.gitFolder, - ): Promise { - try { - const credential = jsonParse( - await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }), - ); - return credential; - } catch (error) { - return undefined; - } - } - - async cleanWorkFolder() { - try { - const workflowFiles = await glob('*.json', { - cwd: this.workflowExportFolder, - absolute: true, - }); - const credentialFiles = await glob('*.json', { - cwd: this.credentialExportFolder, - absolute: true, - }); - const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, { - cwd: this.gitFolder, - absolute: true, - }); - const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, { - cwd: this.gitFolder, - absolute: true, - }); - await Promise.all(tagsFile.map(async (e) => fsRm(e))); - await Promise.all(variablesFile.map(async (e) => fsRm(e))); - await Promise.all(workflowFiles.map(async (e) => fsRm(e))); - await Promise.all(credentialFiles.map(async (e) => fsRm(e))); - LoggerProxy.debug('Cleaned work folder.'); - } catch (error) { - LoggerProxy.error(`Failed to clean work folder: ${(error as Error).message}`); - } + return getCredentialExportPath(credentialsId, this.credentialExportFolder); } async deleteRepositoryFolder() { @@ -123,86 +60,73 @@ export class SourceControlExportService { } } - private async rmDeletedWorkflowsFromExportFolder( - workflowsToBeExported: SharedWorkflow[], - ): Promise> { - const sharedWorkflowsFileNames = new Set( - workflowsToBeExported.map((e) => this.getWorkflowPath(e?.workflow?.name)), - ); - const existingWorkflowsInFolder = new Set( - await glob('*.json', { - cwd: this.workflowExportFolder, - absolute: true, - }), - ); - const deletedWorkflows = new Set(existingWorkflowsInFolder); - for (const elem of sharedWorkflowsFileNames) { - deletedWorkflows.delete(elem); - } + public rmFilesFromExportFolder(filesToBeDeleted: Set): Set { try { - await Promise.all([...deletedWorkflows].map(async (e) => fsRm(e))); + filesToBeDeleted.forEach((e) => rmSync(e)); } catch (error) { LoggerProxy.error(`Failed to delete workflows from work folder: ${(error as Error).message}`); } - return deletedWorkflows; + return filesToBeDeleted; } - private async writeExportableWorkflowsToExportFolder(workflowsToBeExported: SharedWorkflow[]) { + private async writeExportableWorkflowsToExportFolder( + workflowsToBeExported: WorkflowEntity[], + owners: Record, + ) { await Promise.all( workflowsToBeExported.map(async (e) => { - if (!e.workflow) { - LoggerProxy.debug( - `Found no corresponding workflow ${e.workflowId ?? 'unknown'}, skipping export`, - ); - return; - } - const fileName = this.getWorkflowPath(e.workflow?.id); + const fileName = this.getWorkflowPath(e.id); const sanitizedWorkflow: ExportableWorkflow = { - active: e.workflow?.active, - id: e.workflow?.id, - name: e.workflow?.name, - nodes: e.workflow?.nodes, - connections: e.workflow?.connections, - settings: e.workflow?.settings, - triggerCount: e.workflow?.triggerCount, - versionId: e.workflow?.versionId, + id: e.id, + name: e.name, + nodes: e.nodes, + connections: e.connections, + settings: e.settings, + triggerCount: e.triggerCount, + versionId: e.versionId, + owner: owners[e.id], }; - LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`); + LoggerProxy.debug(`Writing workflow ${e.id} to ${fileName}`); return fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2)); }), ); } - async exportWorkflowsToWorkFolder(): Promise { + async exportWorkflowsToWorkFolder(candidates: SourceControlledFile[]): Promise { try { sourceControlFoldersExistCheck([this.workflowExportFolder]); + const workflowIds = candidates.map((e) => e.id); const sharedWorkflows = await Db.collections.SharedWorkflow.find({ - relations: ['workflow', 'role', 'user'], + relations: ['role', 'user'], where: { role: { name: 'owner', scope: 'workflow', }, + workflowId: In(workflowIds), + }, + }); + const workflows = await Db.collections.Workflow.find({ + where: { + id: In(workflowIds), }, }); - // before exporting, figure out which workflows have been deleted and remove them from the export folder - const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows); - // write the workflows to the export folder as json files - await this.writeExportableWorkflowsToExportFolder(sharedWorkflows); - // write list of owners to file - const ownersFileName = this.getOwnersPath(); + // determine owner of each workflow to be exported const owners: Record = {}; sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email)); - await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2)); + + // write the workflows to the export folder as json files + await this.writeExportableWorkflowsToExportFolder(workflows, owners); + + // await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2)); return { count: sharedWorkflows.length, folder: this.workflowExportFolder, - files: sharedWorkflows.map((e) => ({ - id: e?.workflow?.id, - name: this.getWorkflowPath(e?.workflow?.name), + files: workflows.map((e) => ({ + id: e?.id, + name: this.getWorkflowPath(e?.name), })), - removedFiles: [...removedFiles], }; } catch (error) { throw Error(`Failed to export workflows to work folder: ${(error as Error).message}`); @@ -221,7 +145,7 @@ export class SourceControlExportService { files: [], }; } - const fileName = this.getVariablesPath(); + const fileName = getVariablesPath(this.gitFolder); const sanitizedVariables = variables.map((e) => ({ ...e, value: '' })); await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2)); return { @@ -252,7 +176,7 @@ export class SourceControlExportService { }; } const mappings = await Db.collections.WorkflowTagMapping.find(); - const fileName = this.getTagsPath(); + const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); await fsWriteFile( fileName, JSON.stringify( @@ -289,10 +213,7 @@ export class SourceControlExportService { } else if (typeof data[key] === 'object') { data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject); } else if (typeof data[key] === 'string') { - data[key] = - (data[key] as string)?.startsWith('={{') && (data[key] as string)?.includes('$secret') - ? data[key] - : ''; + data[key] = stringContainsExpression(data[key] as string) ? data[key] : ''; } else if (typeof data[key] === 'number') { // TODO: leaving numbers in for now, but maybe we should remove them continue; @@ -305,23 +226,31 @@ export class SourceControlExportService { return data; }; - async exportCredentialsToWorkFolder(): Promise { + async exportCredentialsToWorkFolder(candidates: SourceControlledFile[]): Promise { try { sourceControlFoldersExistCheck([this.credentialExportFolder]); - const sharedCredentials = await Db.collections.SharedCredentials.find({ + const credentialIds = candidates.map((e) => e.id); + const credentialsToBeExported = await Db.collections.SharedCredentials.find({ relations: ['credentials', 'role', 'user'], + where: { + credentialsId: In(credentialIds), + }, }); + let missingIds: string[] = []; + if (credentialsToBeExported.length !== credentialIds.length) { + const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId); + missingIds = credentialIds.filter( + (remote) => foundCredentialIds.findIndex((local) => local === remote) === -1, + ); + } const encryptionKey = await UserSettings.getEncryptionKey(); await Promise.all( - sharedCredentials.map(async (sharedCredential) => { + credentialsToBeExported.map(async (sharedCredential) => { const { name, type, nodesAccess, data, id } = sharedCredential.credentials; const credentialObject = new Credentials({ id, name }, type, nodesAccess, data); const plainData = credentialObject.getData(encryptionKey); const sanitizedData = this.replaceCredentialData(plainData); - const fileName = path.join( - this.credentialExportFolder, - `${sharedCredential.credentials.id}.json`, - ); + const fileName = this.getCredentialsPath(sharedCredential.credentials.id); const sanitizedCredential: ExportableCredential = { id: sharedCredential.credentials.id, name: sharedCredential.credentials.name, @@ -334,12 +263,13 @@ export class SourceControlExportService { }), ); return { - count: sharedCredentials.length, + count: credentialsToBeExported.length, folder: this.credentialExportFolder, - files: sharedCredentials.map((e) => ({ + files: credentialsToBeExported.map((e) => ({ id: e.credentials.id, name: path.join(this.credentialExportFolder, `${e.credentials.name}.json`), })), + missingIds, }; } catch (error) { throw Error(`Failed to export credentials to work folder: ${(error as Error).message}`); diff --git a/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts index 9c1196345802d..b1e42fe936320 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlGit.service.ee.ts @@ -12,10 +12,16 @@ import type { SimpleGitOptions, StatusResult, } from 'simple-git'; -import { simpleGit } from 'simple-git'; import type { SourceControlPreferences } from './types/sourceControlPreferences'; -import { SOURCE_CONTROL_DEFAULT_BRANCH, SOURCE_CONTROL_ORIGIN } from './constants'; +import { + SOURCE_CONTROL_DEFAULT_BRANCH, + SOURCE_CONTROL_DEFAULT_EMAIL, + SOURCE_CONTROL_DEFAULT_NAME, + SOURCE_CONTROL_ORIGIN, +} from './constants'; import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee'; +import type { User } from '../../databases/entities/User'; +import { getInstanceOwner } from '../../UserManagement/UserManagementHelper'; @Service() export class SourceControlGitService { @@ -27,7 +33,7 @@ export class SourceControlGitService { * Run pre-checks before initialising git * Checks for existence of required binaries (git and ssh) */ - preInitCheck(): boolean { + private preInitCheck(): boolean { LoggerProxy.debug('GitService.preCheck'); try { const gitResult = execSync('git --version', { @@ -80,6 +86,8 @@ export class SourceControlGitService { trimmed: false, }; + const { simpleGit } = await import('simple-git'); + this.git = simpleGit(this.gitOptions) // Tell git not to ask for any information via the terminal like for // example the username. As nobody will be able to answer it would @@ -92,7 +100,8 @@ export class SourceControlGitService { } if (!(await this.hasRemote(sourceControlPreferences.repositoryUrl))) { if (sourceControlPreferences.connected && sourceControlPreferences.repositoryUrl) { - await this.initRepository(sourceControlPreferences); + const user = await getInstanceOwner(); + await this.initRepository(sourceControlPreferences, user); } } } @@ -101,9 +110,9 @@ export class SourceControlGitService { this.git = null; } - async checkRepositorySetup(): Promise { + private async checkRepositorySetup(): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (async)'); } if (!(await this.git.checkIsRepo())) { return false; @@ -116,9 +125,9 @@ export class SourceControlGitService { } } - async hasRemote(remote: string): Promise { + private async hasRemote(remote: string): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (async)'); } try { const remotes = await this.git.getRemotes(true); @@ -139,11 +148,12 @@ export class SourceControlGitService { async initRepository( sourceControlPreferences: Pick< SourceControlPreferences, - 'repositoryUrl' | 'authorEmail' | 'authorName' | 'branchName' | 'initRepo' + 'repositoryUrl' | 'branchName' | 'initRepo' >, + user: User, ): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (Promise)'); } if (sourceControlPreferences.initRepo) { try { @@ -161,8 +171,10 @@ export class SourceControlGitService { throw error; } } - await this.git.addConfig('user.email', sourceControlPreferences.authorEmail); - await this.git.addConfig('user.name', sourceControlPreferences.authorName); + await this.setGitUserDetails( + `${user.firstName} ${user.lastName}` ?? SOURCE_CONTROL_DEFAULT_NAME, + user.email ?? SOURCE_CONTROL_DEFAULT_EMAIL, + ); if (sourceControlPreferences.initRepo) { try { const branches = await this.getBranches(); @@ -175,9 +187,17 @@ export class SourceControlGitService { } } + async setGitUserDetails(name: string, email: string): Promise { + if (!this.git) { + throw new Error('Git is not initialized (setGitUserDetails)'); + } + await this.git.addConfig('user.email', name); + await this.git.addConfig('user.name', email); + } + async getBranches(): Promise<{ branches: string[]; currentBranch: string }> { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (getBranches)'); } try { @@ -200,23 +220,16 @@ export class SourceControlGitService { async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (setBranch)'); } await this.git.checkout(branch); await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]); return this.getBranches(); } - async fetch(): Promise { - if (!this.git) { - throw new Error('Git is not initialized'); - } - return this.git.fetch(); - } - async getCurrentBranch(): Promise<{ current: string; remote: string }> { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (getCurrentBranch)'); } const currentBranch = (await this.git.branch()).current; return { @@ -225,49 +238,47 @@ export class SourceControlGitService { }; } - async diff(options?: { target?: string; dots?: '..' | '...' }): Promise { - if (!this.git) { - throw new Error('Git is not initialized'); - } - const currentBranch = await this.getCurrentBranch(); - const target = options?.target ?? currentBranch.remote; - const dots = options?.dots ?? '...'; - return this.git.diffSummary([dots + target]); - } - async diffRemote(): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (diffRemote)'); } const currentBranch = await this.getCurrentBranch(); if (currentBranch.remote) { const target = currentBranch.remote; - return this.git.diffSummary(['...' + target]); + return this.git.diffSummary(['...' + target, '--ignore-all-space']); } return; } async diffLocal(): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (diffLocal)'); } const currentBranch = await this.getCurrentBranch(); if (currentBranch.remote) { const target = currentBranch.current; - return this.git.diffSummary([target]); + return this.git.diffSummary([target, '--ignore-all-space']); } return; } + async fetch(): Promise { + if (!this.git) { + throw new Error('Git is not initialized (fetch)'); + } + return this.git.fetch(); + } + async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (pull)'); } + const params = {}; if (options.ffOnly) { // eslint-disable-next-line @typescript-eslint/naming-convention - return this.git.pull(undefined, undefined, { '--ff-only': null }); + Object.assign(params, { '--ff-only': true }); } - return this.git.pull(); + return this.git.pull(params); } async push( @@ -278,7 +289,7 @@ export class SourceControlGitService { ): Promise { const { force, branch } = options; if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized ({)'); } if (force) { return this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']); @@ -288,7 +299,7 @@ export class SourceControlGitService { async stage(files: Set, deletedFiles?: Set): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (stage)'); } if (deletedFiles?.size) { try { @@ -301,10 +312,10 @@ export class SourceControlGitService { } async resetBranch( - options: { hard?: boolean; target: string } = { hard: false, target: 'HEAD' }, + options: { hard: boolean; target: string } = { hard: true, target: 'HEAD' }, ): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (Promise)'); } if (options?.hard) { return this.git.raw(['reset', '--hard', options.target]); @@ -316,14 +327,14 @@ export class SourceControlGitService { async commit(message: string): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (commit)'); } return this.git.commit(message); } async status(): Promise { if (!this.git) { - throw new Error('Git is not initialized'); + throw new Error('Git is not initialized (status)'); } const statusResult = await this.git.status(); return statusResult; diff --git a/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts index 18b699330f09c..93ee873173ae3 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts @@ -1,25 +1,61 @@ -import { Container } from 'typedi'; +import Container from 'typedi'; +import { License } from '@/License'; import { generateKeyPairSync } from 'crypto'; -import sshpk from 'sshpk'; +import type { KeyPair } from './types/keyPair'; import { constants as fsConstants, mkdirSync, accessSync } from 'fs'; import { LoggerProxy } from 'n8n-workflow'; -import { License } from '@/License'; -import type { KeyPair } from './types/keyPair'; -import { SOURCE_CONTROL_GIT_KEY_COMMENT } from './constants'; +import { + SOURCE_CONTROL_GIT_KEY_COMMENT, + SOURCE_CONTROL_TAGS_EXPORT_FILE, + SOURCE_CONTROL_VARIABLES_EXPORT_FILE, +} from './constants'; +import type { SourceControlledFile } from './types/sourceControlledFile'; +import path from 'path'; + +export function stringContainsExpression(testString: string): boolean { + return /^=.*\{\{.*\}\}/.test(testString); +} + +export function getWorkflowExportPath(workflowId: string, workflowExportFolder: string): string { + return path.join(workflowExportFolder, `${workflowId}.json`); +} -export function sourceControlFoldersExistCheck(folders: string[]) { +export function getCredentialExportPath( + credentialId: string, + credentialExportFolder: string, +): string { + return path.join(credentialExportFolder, `${credentialId}.json`); +} + +export function getVariablesPath(gitFolder: string): string { + return path.join(gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE); +} + +export function getTagsPath(gitFolder: string): string { + return path.join(gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); +} + +export function sourceControlFoldersExistCheck( + folders: string[], + createIfNotExists = true, +): boolean { // running these file access function synchronously to avoid race conditions + let existed = true; folders.forEach((folder) => { try { accessSync(folder, fsConstants.F_OK); } catch { - try { - mkdirSync(folder); - } catch (error) { - LoggerProxy.error((error as Error).message); + existed = false; + if (createIfNotExists) { + try { + mkdirSync(folder, { recursive: true }); + } catch (error) { + LoggerProxy.error((error as Error).message); + } } } }); + return existed; } export function isSourceControlLicensed() { @@ -27,7 +63,8 @@ export function isSourceControlLicensed() { return license.isSourceControlLicensed(); } -export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { +export async function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { + const sshpk = await import('sshpk'); const keyPair: KeyPair = { publicKey: '', privateKey: '', @@ -65,3 +102,76 @@ export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { publicKey: keyPair.publicKey, }; } + +export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' { + if (repoUrl.includes('github.com')) { + return 'github'; + } else if (repoUrl.includes('gitlab.com')) { + return 'gitlab'; + } + return 'other'; +} + +function filterSourceControlledFilesUniqueIds(files: SourceControlledFile[]) { + return ( + files.filter((file, index, self) => { + return self.findIndex((f) => f.id === file.id) === index; + }) || [] + ); +} + +export function getTrackingInformationFromPullResult(result: SourceControlledFile[]): { + cred_conflicts: number; + workflow_conflicts: number; + workflow_updates: number; +} { + const uniques = filterSourceControlledFilesUniqueIds(result); + return { + cred_conflicts: uniques.filter( + (file) => + file.type === 'credential' && file.status === 'modified' && file.location === 'local', + ).length, + workflow_conflicts: uniques.filter( + (file) => file.type === 'workflow' && file.status === 'modified' && file.location === 'local', + ).length, + workflow_updates: uniques.filter((file) => file.type === 'workflow').length, + }; +} + +export function getTrackingInformationFromPrePushResult(result: SourceControlledFile[]): { + workflows_eligible: number; + workflows_eligible_with_conflicts: number; + creds_eligible: number; + creds_eligible_with_conflicts: number; + variables_eligible: number; +} { + const uniques = filterSourceControlledFilesUniqueIds(result); + return { + workflows_eligible: uniques.filter((file) => file.type === 'workflow').length, + workflows_eligible_with_conflicts: uniques.filter( + (file) => file.type === 'workflow' && file.conflict, + ).length, + creds_eligible: uniques.filter((file) => file.type === 'credential').length, + creds_eligible_with_conflicts: uniques.filter( + (file) => file.type === 'credential' && file.conflict, + ).length, + variables_eligible: uniques.filter((file) => file.type === 'variables').length, + }; +} + +export function getTrackingInformationFromPostPushResult(result: SourceControlledFile[]): { + workflows_eligible: number; + workflows_pushed: number; + creds_pushed: number; + variables_pushed: number; +} { + const uniques = filterSourceControlledFilesUniqueIds(result); + return { + workflows_pushed: uniques.filter((file) => file.pushed && file.type === 'workflow').length ?? 0, + workflows_eligible: uniques.filter((file) => file.type === 'workflow').length ?? 0, + creds_pushed: + uniques.filter((file) => file.pushed && file.file.startsWith('credential_stubs')).length ?? 0, + variables_pushed: + uniques.filter((file) => file.pushed && file.file.startsWith('variable_stubs')).length ?? 0, + }; +} diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index f0977df1d633a..e7126350b2063 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -3,7 +3,6 @@ import path from 'path'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, - SOURCE_CONTROL_OWNERS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, @@ -16,15 +15,16 @@ import { Credentials, UserSettings } from 'n8n-core'; import type { IWorkflowToImport } from '@/Interfaces'; import type { ExportableCredential } from './types/exportableCredential'; import { Variables } from '@db/entities/Variables'; -import type { ImportResult } from './types/importResult'; import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import type { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; -import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner'; -import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { In } from 'typeorm'; -import { isUniqueConstraintError } from '../../ResponseHelper'; +import { isUniqueConstraintError } from '@/ResponseHelper'; +import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId'; +import { getCredentialExportPath, getWorkflowExportPath } from './sourceControlHelper.ee'; +import type { SourceControlledFile } from './types/sourceControlledFile'; @Service() export class SourceControlImportService { @@ -143,65 +143,113 @@ export class SourceControlImportService { return importCredentialsResult.filter((e) => e !== undefined); } - private async importVariablesFromFile(valueOverrides?: { - [key: string]: string; - }): Promise<{ imported: string[] }> { + public async getRemoteVersionIdsFromFiles(): Promise { + const remoteWorkflowFiles = await glob('*.json', { + cwd: this.workflowExportFolder, + absolute: true, + }); + const remoteWorkflowFilesParsed = await Promise.all( + remoteWorkflowFiles.map(async (file) => { + LoggerProxy.debug(`Parsing workflow file ${file}`); + const remote = jsonParse(await fsReadFile(file, { encoding: 'utf8' })); + if (!remote?.id) { + return undefined; + } + return { + id: remote.id, + versionId: remote.versionId, + name: remote.name, + remoteId: remote.id, + filename: getWorkflowExportPath(remote.id, this.workflowExportFolder), + } as SourceControlWorkflowVersionId; + }), + ); + return remoteWorkflowFilesParsed.filter( + (e) => e !== undefined, + ) as SourceControlWorkflowVersionId[]; + } + + public async getLocalVersionIdsFromDb(): Promise { + const localWorkflows = await Db.collections.Workflow.find({ + select: ['id', 'name', 'versionId', 'updatedAt'], + }); + return localWorkflows.map((local) => ({ + id: local.id, + versionId: local.versionId, + name: local.name, + localId: local.id, + filename: getWorkflowExportPath(local.id, this.workflowExportFolder), + updatedAt: local.updatedAt.toISOString(), + })) as SourceControlWorkflowVersionId[]; + } + + public async getRemoteCredentialsFromFiles(): Promise< + Array + > { + const remoteCredentialFiles = await glob('*.json', { + cwd: this.credentialExportFolder, + absolute: true, + }); + const remoteCredentialFilesParsed = await Promise.all( + remoteCredentialFiles.map(async (file) => { + LoggerProxy.debug(`Parsing credential file ${file}`); + const remote = jsonParse( + await fsReadFile(file, { encoding: 'utf8' }), + ); + if (!remote?.id) { + return undefined; + } + return { + ...remote, + filename: getCredentialExportPath(remote.id, this.credentialExportFolder), + }; + }), + ); + return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array< + ExportableCredential & { filename: string } + >; + } + + public async getLocalCredentialsFromDb(): Promise< + Array + > { + const localCredentials = await Db.collections.Credentials.find({ + select: ['id', 'name', 'type', 'nodesAccess'], + }); + return localCredentials.map((local) => ({ + id: local.id, + name: local.name, + type: local.type, + nodesAccess: local.nodesAccess, + filename: getCredentialExportPath(local.id, this.credentialExportFolder), + })) as Array; + } + + public async getRemoteVariablesFromFile(): Promise { const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, { cwd: this.gitFolder, absolute: true, }); - const result: { imported: string[] } = { imported: [] }; if (variablesFile.length > 0) { LoggerProxy.debug(`Importing variables from file ${variablesFile[0]}`); - const importedVariables = jsonParse>>( - await fsReadFile(variablesFile[0], { encoding: 'utf8' }), - { fallbackValue: [] }, - ); - const overriddenKeys = Object.keys(valueOverrides ?? {}); - - for (const variable of importedVariables) { - if (!variable.key) { - continue; - } - // by default no value is stored remotely, so an empty string is retuned - // it must be changed to undefined so as to not overwrite existing values! - if (variable.value === '') { - variable.value = undefined; - } - if (overriddenKeys.includes(variable.key) && valueOverrides) { - variable.value = valueOverrides[variable.key]; - overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1); - } - try { - await Db.collections.Variables.upsert({ ...variable }, ['id']); - } catch (errorUpsert) { - if (isUniqueConstraintError(errorUpsert as Error)) { - LoggerProxy.debug(`Variable ${variable.key} already exists, updating instead`); - try { - await Db.collections.Variables.update({ key: variable.key }, { ...variable }); - } catch (errorUpdate) { - LoggerProxy.debug(`Failed to update variable ${variable.key}, skipping`); - LoggerProxy.debug((errorUpdate as Error).message); - } - } - } finally { - result.imported.push(variable.key); - } - } - - // add remaining overrides as new variables - if (overriddenKeys.length > 0 && valueOverrides) { - for (const key of overriddenKeys) { - result.imported.push(key); - const newVariable = new Variables({ key, value: valueOverrides[key] }); - await Db.collections.Variables.save(newVariable); - } - } + return jsonParse(await fsReadFile(variablesFile[0], { encoding: 'utf8' }), { + fallbackValue: [], + }); } - return result; + return []; + } + + public async getLocalVariablesFromDb(): Promise { + const localVariables = await Db.collections.Variables.find({ + select: ['id', 'key', 'type', 'value'], + }); + return localVariables; } - private async importTagsFromFile() { + public async getRemoteTagsAndMappingsFromFile(): Promise<{ + tags: TagEntity[]; + mappings: WorkflowTagMapping[]; + }> { const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, { cwd: this.gitFolder, absolute: true, @@ -212,110 +260,51 @@ export class SourceControlImportService { await fsReadFile(tagsFile[0], { encoding: 'utf8' }), { fallbackValue: { tags: [], mappings: [] } }, ); - const existingWorkflowIds = new Set( - ( - await Db.collections.Workflow.find({ - select: ['id'], - }) - ).map((e) => e.id), - ); - - await Promise.all( - mappedTags.tags.map(async (tag) => { - await Db.collections.Tag.upsert( - { - ...tag, - }, - { - skipUpdateIfNoValuesChanged: true, - conflictPaths: { id: true }, - }, - ); - }), - ); - await Promise.all( - mappedTags.mappings.map(async (mapping) => { - if (!existingWorkflowIds.has(String(mapping.workflowId))) return; - await Db.collections.WorkflowTagMapping.upsert( - { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, - { - skipUpdateIfNoValuesChanged: true, - conflictPaths: { tagId: true, workflowId: true }, - }, - ); - }), - ); return mappedTags; } return { tags: [], mappings: [] }; } - private async importWorkflowsFromFiles( - userId: string, - ): Promise> { - const workflowFiles = await glob('*.json', { - cwd: this.workflowExportFolder, - absolute: true, + public async getLocalTagsAndMappingsFromDb(): Promise<{ + tags: TagEntity[]; + mappings: WorkflowTagMapping[]; + }> { + const localTags = await Db.collections.Tag.find({ + select: ['id', 'name'], }); - - const existingWorkflows = await Db.collections.Workflow.find({ - select: ['id', 'name', 'active', 'versionId'], + const localMappings = await Db.collections.WorkflowTagMapping.find({ + select: ['workflowId', 'tagId'], }); + return { tags: localTags, mappings: localMappings }; + } + public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { const ownerWorkflowRole = await this.getOwnerWorkflowRole(); const workflowRunner = Container.get(ActiveWorkflowRunner); - - // read owner file if it exists and map workflow ids to owner emails - // then find existing users with those emails or fallback to passed in userId - const ownerRecords: Record = {}; - const ownersFile = await glob(SOURCE_CONTROL_OWNERS_EXPORT_FILE, { - cwd: this.gitFolder, - absolute: true, + const candidateIds = candidates.map((c) => c.id); + const existingWorkflows = await Db.collections.Workflow.find({ + where: { + id: In(candidateIds), + }, + select: ['id', 'name', 'versionId', 'active'], }); - if (ownersFile.length > 0) { - LoggerProxy.debug(`Reading workflow owners from file ${ownersFile[0]}`); - const ownerEmails = jsonParse>( - await fsReadFile(ownersFile[0], { encoding: 'utf8' }), - { fallbackValue: {} }, - ); - if (ownerEmails) { - const uniqueOwnerEmails = new Set(Object.values(ownerEmails)); - const existingUsers = await Db.collections.User.find({ - where: { email: In([...uniqueOwnerEmails]) }, - }); - Object.keys(ownerEmails).forEach((workflowId) => { - ownerRecords[workflowId] = - existingUsers.find((e) => e.email === ownerEmails[workflowId])?.id ?? userId; - }); - } - } - - let importWorkflowsResult = new Array<{ id: string; name: string } | undefined>(); - const allSharedWorkflows = await Db.collections.SharedWorkflow.find({ + where: { + workflowId: In(candidateIds), + }, select: ['workflowId', 'roleId', 'userId'], }); - - importWorkflowsResult = await Promise.all( - workflowFiles.map(async (file) => { - LoggerProxy.debug(`Parsing workflow file ${file}`); - const importedWorkflow = jsonParse( - await fsReadFile(file, { encoding: 'utf8' }), + const cachedOwnerIds = new Map(); + const importWorkflowsResult = await Promise.all( + candidates.map(async (candidate) => { + LoggerProxy.debug(`Parsing workflow file ${candidate.file}`); + const importedWorkflow = jsonParse( + await fsReadFile(candidate.file, { encoding: 'utf8' }), ); if (!importedWorkflow?.id) { return; } const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); - if (existingWorkflow?.versionId === importedWorkflow.versionId) { - LoggerProxy.debug( - `Skipping import of workflow ${importedWorkflow.id ?? 'n/a'} - versionId is up to date`, - ); - return { - id: importedWorkflow.id ?? 'n/a', - name: 'skipped', - }; - } - LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`); importedWorkflow.active = existingWorkflow?.active ?? false; LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); const upsertResult = await Db.collections.Workflow.upsert({ ...importedWorkflow }, ['id']); @@ -324,12 +313,31 @@ export class SourceControlImportService { } // Update workflow owner to the user who exported the workflow, if that user exists // in the instance, and the workflow doesn't already have an owner - const workflowOwnerId = ownerRecords[importedWorkflow.id] ?? userId; + let workflowOwnerId = userId; + if (cachedOwnerIds.has(importedWorkflow.owner)) { + workflowOwnerId = cachedOwnerIds.get(importedWorkflow.owner) ?? userId; + } else { + const foundUser = await Db.collections.User.findOne({ + where: { + email: importedWorkflow.owner, + }, + select: ['id'], + }); + if (foundUser) { + cachedOwnerIds.set(importedWorkflow.owner, foundUser.id); + workflowOwnerId = foundUser.id; + } + } + const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.roleId === ownerWorkflowRole.id, + (e) => + e.workflowId === importedWorkflow.id && + e.roleId.toString() === ownerWorkflowRole.id.toString(), ); const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.userId === workflowOwnerId, + (e) => + e.workflowId === importedWorkflow.id && + e.roleId.toString() === workflowOwnerId.toString(), ); if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { // no owner exists yet, so create one @@ -361,39 +369,218 @@ export class SourceControlImportService { // try activating the imported workflow LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`); await workflowRunner.add(existingWorkflow.id, 'activate'); + // update the versionId of the workflow to match the imported workflow } catch (error) { LoggerProxy.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); + } finally { + await Db.collections.Workflow.update( + { id: existingWorkflow.id }, + { versionId: importedWorkflow.versionId }, + ); } } return { id: importedWorkflow.id ?? 'unknown', - name: file, + name: candidate.file, }; }), ); - return importWorkflowsResult.filter((e) => e !== undefined) as Array<{ id: string; name: string; }>; } - async importFromWorkFolder(options: SourceControllPullOptions): Promise { + public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { + const candidateIds = candidates.map((c) => c.id); + const existingCredentials = await Db.collections.Credentials.find({ + where: { + id: In(candidateIds), + }, + select: ['id', 'name', 'type', 'data'], + }); + const ownerCredentialRole = await this.getOwnerCredentialRole(); + const ownerGlobalRole = await this.getOwnerGlobalRole(); + const existingSharedCredentials = await Db.collections.SharedCredentials.find({ + select: ['userId', 'credentialsId', 'roleId'], + where: { + credentialsId: In(candidateIds), + roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), + }, + }); + const encryptionKey = await UserSettings.getEncryptionKey(); + let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; + importCredentialsResult = await Promise.all( + candidates.map(async (candidate) => { + LoggerProxy.debug(`Importing credentials file ${candidate.file}`); + const credential = jsonParse( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + ); + const existingCredential = existingCredentials.find( + (e) => e.id === credential.id && e.type === credential.type, + ); + const sharedOwner = existingSharedCredentials.find( + (e) => e.credentialsId === credential.id, + ); + + const { name, type, data, id, nodesAccess } = credential; + const newCredentialObject = new Credentials({ id, name }, type, []); + if (existingCredential?.data) { + newCredentialObject.data = existingCredential.data; + } else { + newCredentialObject.setData(data, encryptionKey); + } + newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; + + LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`); + await Db.collections.Credentials.upsert(newCredentialObject, ['id']); + + if (!sharedOwner) { + const newSharedCredential = new SharedCredentials(); + newSharedCredential.credentialsId = newCredentialObject.id as string; + newSharedCredential.userId = userId; + newSharedCredential.roleId = ownerCredentialRole.id; + + await Db.collections.SharedCredentials.upsert({ ...newSharedCredential }, [ + 'credentialsId', + 'userId', + ]); + } + + return { + id: newCredentialObject.id as string, + name: newCredentialObject.name, + type: newCredentialObject.type, + }; + }), + ); + return importCredentialsResult.filter((e) => e !== undefined); + } + + public async importTagsFromWorkFolder(candidate: SourceControlledFile) { + let mappedTags; + try { + LoggerProxy.debug(`Importing tags from file ${candidate.file}`); + mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + { fallbackValue: { tags: [], mappings: [] } }, + ); + } catch (error) { + LoggerProxy.error(`Failed to import tags from file ${candidate.file}`, error as Error); + return; + } + + if (mappedTags.mappings.length === 0 && mappedTags.tags.length === 0) { + return; + } + + const existingWorkflowIds = new Set( + ( + await Db.collections.Workflow.find({ + select: ['id'], + }) + ).map((e) => e.id), + ); + + await Promise.all( + mappedTags.tags.map(async (tag) => { + const findByName = await Db.collections.Tag.findOne({ + where: { name: tag.name }, + select: ['id'], + }); + if (findByName && findByName.id !== tag.id) { + throw new Error( + `A tag with the name ${tag.name} already exists locally.
Please either rename the local tag, or the remote one with the id ${tag.id} in the tags.json file.`, + ); + } + await Db.collections.Tag.upsert( + { + ...tag, + }, + { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { id: true }, + }, + ); + }), + ); + + await Promise.all( + mappedTags.mappings.map(async (mapping) => { + if (!existingWorkflowIds.has(String(mapping.workflowId))) return; + await Db.collections.WorkflowTagMapping.upsert( + { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, + { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { tagId: true, workflowId: true }, + }, + ); + }), + ); + + return mappedTags; + } + + public async importVariablesFromWorkFolder( + candidate: SourceControlledFile, + valueOverrides?: { + [key: string]: string; + }, + ) { + const result: { imported: string[] } = { imported: [] }; + let importedVariables; try { - const importedVariables = await this.importVariablesFromFile(options.variables); - const importedCredentials = await this.importCredentialsFromFiles(options.userId); - const importWorkflows = await this.importWorkflowsFromFiles(options.userId); - const importTags = await this.importTagsFromFile(); - - return { - variables: importedVariables, - credentials: importedCredentials, - workflows: importWorkflows, - tags: importTags, - }; + LoggerProxy.debug(`Importing variables from file ${candidate.file}`); + importedVariables = jsonParse>>( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + { fallbackValue: [] }, + ); } catch (error) { - throw Error(`Failed to import workflows from work folder: ${(error as Error).message}`); + LoggerProxy.error(`Failed to import tags from file ${candidate.file}`, error as Error); + return; + } + const overriddenKeys = Object.keys(valueOverrides ?? {}); + + for (const variable of importedVariables) { + if (!variable.key) { + continue; + } + // by default no value is stored remotely, so an empty string is retuned + // it must be changed to undefined so as to not overwrite existing values! + if (variable.value === '') { + variable.value = undefined; + } + if (overriddenKeys.includes(variable.key) && valueOverrides) { + variable.value = valueOverrides[variable.key]; + overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1); + } + try { + await Db.collections.Variables.upsert({ ...variable }, ['id']); + } catch (errorUpsert) { + if (isUniqueConstraintError(errorUpsert as Error)) { + LoggerProxy.debug(`Variable ${variable.key} already exists, updating instead`); + try { + await Db.collections.Variables.update({ key: variable.key }, { ...variable }); + } catch (errorUpdate) { + LoggerProxy.debug(`Failed to update variable ${variable.key}, skipping`); + LoggerProxy.debug((errorUpdate as Error).message); + } + } + } finally { + result.imported.push(variable.key); + } } + + // add remaining overrides as new variables + if (overriddenKeys.length > 0 && valueOverrides) { + for (const key of overriddenKeys) { + result.imported.push(key); + const newVariable = new Variables({ key, value: valueOverrides[key] }); + await Db.collections.Variables.save(newVariable); + } + } + + return result; } } diff --git a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts index ef71321aabdad..7e447ef576400 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts @@ -53,6 +53,14 @@ export class SourceControlPreferencesService { ); } + public isSourceControlSetup() { + return ( + this.isSourceControlLicensedAndEnabled() && + this.getPreferences().repositoryUrl && + this.getPreferences().branchName + ); + } + getPublicKey(): string { try { return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' }); @@ -80,7 +88,7 @@ export class SourceControlPreferencesService { */ async generateAndSaveKeyPair(): Promise { sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); - const keyPair = generateSshKeyPair('ed25519'); + const keyPair = await generateSshKeyPair('ed25519'); if (keyPair.publicKey && keyPair.privateKey) { try { await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, { diff --git a/packages/cli/src/environments/sourceControl/types/exportResult.ts b/packages/cli/src/environments/sourceControl/types/exportResult.ts index b7c13f3f56492..976b4ca0c325f 100644 --- a/packages/cli/src/environments/sourceControl/types/exportResult.ts +++ b/packages/cli/src/environments/sourceControl/types/exportResult.ts @@ -6,4 +6,5 @@ export interface ExportResult { name: string; }>; removedFiles?: string[]; + missingIds?: string[]; } diff --git a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts index 15d405fbb7fa7..26b866ddc0231 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts @@ -1,7 +1,6 @@ import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow'; export interface ExportableWorkflow { - active: boolean; id: string; name: string; nodes: INode[]; @@ -9,4 +8,5 @@ export interface ExportableWorkflow { settings?: IWorkflowSettings; triggerCount: number; versionId: string; + owner: string; } diff --git a/packages/cli/src/environments/sourceControl/types/requests.ts b/packages/cli/src/environments/sourceControl/types/requests.ts index 7e2d0d9f7b2c1..5fa165b5be2d4 100644 --- a/packages/cli/src/environments/sourceControl/types/requests.ts +++ b/packages/cli/src/environments/sourceControl/types/requests.ts @@ -8,6 +8,7 @@ import type { SourceControlPushWorkFolder } from './sourceControlPushWorkFolder' import type { SourceControlPullWorkFolder } from './sourceControlPullWorkFolder'; import type { SourceControlDisconnect } from './sourceControlDisconnect'; import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly'; +import type { SourceControlGetStatus } from './sourceControlGetStatus'; export declare namespace SourceControlRequest { type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial, {}>; @@ -19,4 +20,5 @@ export declare namespace SourceControlRequest { type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>; type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>; type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>; + type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>; } diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlGetStatus.ts b/packages/cli/src/environments/sourceControl/types/sourceControlGetStatus.ts new file mode 100644 index 0000000000000..d8f30edff0e0a --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/sourceControlGetStatus.ts @@ -0,0 +1,32 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +function booleanFromString(value: string | boolean): boolean { + if (typeof value === 'boolean') { + return value; + } + return value === 'true'; +} + +export class SourceControlGetStatus { + @IsString() + @IsOptional() + direction: 'push' | 'pull'; + + @IsBoolean() + @IsOptional() + preferLocalVersion: boolean; + + @IsBoolean() + @IsOptional() + verbose: boolean; + + constructor(values: { + direction: 'push' | 'pull'; + preferLocalVersion: string | boolean; + verbose: string | boolean; + }) { + this.direction = values.direction || 'push'; + this.preferLocalVersion = booleanFromString(values.preferLocalVersion) || true; + this.verbose = booleanFromString(values.verbose) || false; + } +} diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts b/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts index 5cbecfb134e9d..970507cdfe696 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsEmail, IsHexColor, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator'; export class SourceControlPreferences { constructor(preferences: Partial | undefined = undefined) { @@ -11,12 +11,6 @@ export class SourceControlPreferences { @IsString() repositoryUrl: string; - @IsString() - authorName: string; - - @IsEmail() - authorEmail: string; - @IsString() branchName = 'main'; @@ -45,8 +39,6 @@ export class SourceControlPreferences { return new SourceControlPreferences({ connected: preferences.connected ?? defaultPreferences.connected, repositoryUrl: preferences.repositoryUrl ?? defaultPreferences.repositoryUrl, - authorName: preferences.authorName ?? defaultPreferences.authorName, - authorEmail: preferences.authorEmail ?? defaultPreferences.authorEmail, branchName: preferences.branchName ?? defaultPreferences.branchName, branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly, branchColor: preferences.branchColor ?? defaultPreferences.branchColor, diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlPullWorkFolder.ts b/packages/cli/src/environments/sourceControl/types/sourceControlPullWorkFolder.ts index ef9d3d6650d39..e91f615fac0da 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlPullWorkFolder.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlPullWorkFolder.ts @@ -24,6 +24,4 @@ export class SourceControllPullOptions { force?: boolean; variables?: { [key: string]: string }; - - importAfterPull?: boolean = true; } diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlPushWorkFolder.ts b/packages/cli/src/environments/sourceControl/types/sourceControlPushWorkFolder.ts index 0fef05da1e685..a6b23025d87aa 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlPushWorkFolder.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlPushWorkFolder.ts @@ -1,4 +1,5 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import type { SourceControlledFile } from './sourceControlledFile'; export class SourceControlPushWorkFolder { @IsBoolean() @@ -6,16 +7,7 @@ export class SourceControlPushWorkFolder { force?: boolean; @IsString({ each: true }) - @IsOptional() - fileNames?: Set; - - @IsString({ each: true }) - @IsOptional() - workflowIds?: Set; - - @IsString({ each: true }) - @IsOptional() - credentialIds?: Set; + fileNames: SourceControlledFile[]; @IsString() @IsOptional() diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlWorkflowVersionId.ts b/packages/cli/src/environments/sourceControl/types/sourceControlWorkflowVersionId.ts new file mode 100644 index 0000000000000..9afbf4e6fbb13 --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/sourceControlWorkflowVersionId.ts @@ -0,0 +1,9 @@ +export interface SourceControlWorkflowVersionId { + id: string; + versionId: string; + filename: string; + name?: string; + localId?: string; + remoteId?: string; + updatedAt?: string; +} diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts b/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts index 165621ebc6126..5bbf75921b2f8 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts @@ -5,6 +5,8 @@ export type SourceControlledFileStatus = | 'created' | 'renamed' | 'conflicted' + | 'ignored' + | 'staged' | 'unknown'; export type SourceControlledFileLocation = 'local' | 'remote'; export type SourceControlledFileType = 'credential' | 'workflow' | 'tags' | 'variables' | 'file'; @@ -17,4 +19,5 @@ export type SourceControlledFile = { location: SourceControlledFileLocation; conflict: boolean; updatedAt: string; + pushed?: boolean; }; diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index 33bbe16d365a5..429c656de33eb 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -21,7 +21,7 @@ export class EEVariablesService extends VariablesService { if (variable.key.replace(/[A-Za-z0-9_]/g, '').length !== 0) { throw new VariablesValidationError('key can only contain characters A-Za-z0-9_'); } - if (variable.value.length > 255) { + if (variable.value?.length > 255) { throw new VariablesValidationError('value cannot be longer than 255 characters'); } } diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index f278bac24fc83..02a01ed6915e1 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -10,7 +10,8 @@ import { getLogger } from '@/Logger'; import { License } from '@/License'; import { LicenseService } from '@/license/License.service'; import { N8N_VERSION } from '@/constants'; -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; +import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -105,11 +106,18 @@ export class Telemetry { this.executionCountsBuffer = {}; + const sourceControlPreferences = Container.get( + SourceControlPreferencesService, + ).getPreferences(); + // License info const pulsePacket = { plan_name_current: this.license.getPlanName(), quota: this.license.getTriggerLimit(), usage: await LicenseService.getActiveTriggerCount(), + source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), + branchName: sourceControlPreferences.branchName, + read_only_instance: sourceControlPreferences.branchReadOnly, }; allPromises.push(this.track('pulse', pulsePacket)); return Promise.all(allPromises); diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 14f2333227d73..24c4cc85542e6 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -222,16 +222,24 @@ export class WorkflowsService { ); } - // Update the workflow's version - workflow.versionId = uuid(); - - LoggerProxy.verbose( - `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, - { - previousVersionId: shared.workflow.versionId, - newVersionId: workflow.versionId, - }, - ); + if ( + Object.keys(workflow).length === 3 && + workflow.id !== undefined && + workflow.versionId !== undefined && + workflow.active !== undefined + ) { + // we're just updating the active status of the workflow, don't update the versionId + } else { + // Update the workflow's version + workflow.versionId = uuid(); + LoggerProxy.verbose( + `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, + { + previousVersionId: shared.workflow.versionId, + newVersionId: workflow.versionId, + }, + ); + } // check credentials for old format await WorkflowHelpers.replaceInvalidCredentials(workflow); diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts new file mode 100644 index 0000000000000..1e4852d7ca319 --- /dev/null +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -0,0 +1,72 @@ +import type { SuperAgentTest } from 'supertest'; +import { SOURCE_CONTROL_API_ROOT } from '@/environments/sourceControl/constants'; +import * as testDb from '../shared/testDb'; +import * as utils from '../shared/utils/'; +import type { User } from '@db/entities/User'; +import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; +import Container from 'typedi'; +import { License } from '@/License'; +import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; +import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; +import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; + +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; +let owner: User; +let member: User; + +const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); + +const testServer = utils.setupTestServer({ + endpointGroups: ['sourceControl', 'license', 'auth'], + enabledFeatures: ['feat:sourceControl', 'feat:sharing'], +}); + +beforeAll(async () => { + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalMemberRole = await testDb.getGlobalMemberRole(); + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + member = await testDb.createUser({ globalRole: globalMemberRole }); + authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); + + Container.get(License).isSourceControlLicensed = () => true; + Container.get(SourceControlPreferencesService).isSourceControlConnected = () => true; +}); + +describe('GET /sourceControl/preferences', () => { + test('should return Source Control preferences', async () => { + await authOwnerAgent + .get(`/${SOURCE_CONTROL_API_ROOT}/preferences`) + .expect(200) + .expect((res) => { + return 'repositoryUrl' in res.body && 'branchName' in res.body; + }); + }); + + test('should return repo sync status', async () => { + Container.get(SourceControlService).getStatus = async () => { + return [ + { + id: 'haQetoXq9GxHSkft', + name: 'My workflow 6 edit', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: true, + file: '/Users/michael/.n8n/git/workflows/haQetoXq9GxHSkft.json', + updatedAt: '2023-07-14T11:24:41.000Z', + }, + ] as SourceControlledFile[]; + }; + await authOwnerAgent + .get(`/${SOURCE_CONTROL_API_ROOT}/get-status`) + .query({ direction: 'push', preferLocalVersion: 'true', verbose: 'false' }) + .expect(200) + .expect((res) => { + const data: SourceControlledFile[] = res.body.data; + expect(data.length).toBe(1); + expect(data[0].id).toBe('haQetoXq9GxHSkft'); + }); + }); +}); diff --git a/packages/cli/test/integration/environments/VersionControl.test.ts b/packages/cli/test/integration/environments/VersionControl.test.ts deleted file mode 100644 index 1b85c92fce7bb..0000000000000 --- a/packages/cli/test/integration/environments/VersionControl.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SuperAgentTest } from 'supertest'; -import { SOURCE_CONTROL_API_ROOT } from '@/environments/sourceControl/constants'; -import * as testDb from '../shared/testDb'; -import * as utils from '../shared/utils/'; - -let authOwnerAgent: SuperAgentTest; - -const testServer = utils.setupTestServer({ - endpointGroups: ['sourceControl'], - enabledFeatures: ['feat:sourceControl'], -}); - -beforeAll(async () => { - const owner = await testDb.createOwner(); - authOwnerAgent = testServer.authAgentFor(owner); -}); - -describe('GET /sourceControl/preferences', () => { - test('should return Source Control preferences', async () => { - await authOwnerAgent - .get(`/${SOURCE_CONTROL_API_ROOT}/preferences`) - .expect(200) - .expect((res) => { - return 'repositoryUrl' in res.body && 'branchName' in res.body; - }); - }); -}); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index e52826bf4c52c..23f455d59cff8 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -759,13 +759,12 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const { versionId: memberVersionId } = memberGetResponse.body.data; await authMemberAgent .patch(`/workflows/${id}`) - .send({ active: true, versionId: memberVersionId }); - + .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); // owner blocked from activating workflow const activationAttemptResponse = await authOwnerAgent .patch(`/workflows/${id}`) - .send({ active: true, versionId: ownerVersionId }); + .send({ active: true, versionId: ownerVersionId, name: 'Update by owner' }); expect(activationAttemptResponse.status).toBe(400); expect(activationAttemptResponse.body.code).toBe(100); @@ -793,13 +792,13 @@ describe('PATCH /workflows/:id - validate interim updates', () => { await authOwnerAgent .patch(`/workflows/${id}`) - .send({ active: true, versionId: ownerSecondVersionId }); + .send({ active: true, versionId: ownerSecondVersionId, name: 'Owner update again' }); // member blocked from activating workflow const updateAttemptResponse = await authMemberAgent .patch(`/workflows/${id}`) - .send({ active: true, versionId: memberVersionId }); + .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); expect(updateAttemptResponse.status).toBe(400); expect(updateAttemptResponse.body.code).toBe(100); diff --git a/packages/cli/test/unit/SourceControl.test.ts b/packages/cli/test/unit/SourceControl.test.ts new file mode 100644 index 0000000000000..e33b67aa8f7b5 --- /dev/null +++ b/packages/cli/test/unit/SourceControl.test.ts @@ -0,0 +1,256 @@ +import Container from 'typedi'; +import { + generateSshKeyPair, + getRepoType, + getTrackingInformationFromPostPushResult, + getTrackingInformationFromPrePushResult, + getTrackingInformationFromPullResult, + sourceControlFoldersExistCheck, +} from '@/environments/sourceControl/sourceControlHelper.ee'; +import { License } from '@/License'; +import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; +import { UserSettings } from 'n8n-core'; +import path from 'path'; +import { + SOURCE_CONTROL_SSH_FOLDER, + SOURCE_CONTROL_GIT_FOLDER, + SOURCE_CONTROL_SSH_KEY_NAME, +} from '@/environments/sourceControl/constants'; +import { LoggerProxy } from 'n8n-workflow'; +import { getLogger } from '@/Logger'; +import { constants as fsConstants, accessSync } from 'fs'; +import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; + +const pushResult: SourceControlledFile[] = [ + { + file: 'credential_stubs/kkookWGIeey9K4Kt.json', + id: 'kkookWGIeey9K4Kt', + name: '(deleted)', + type: 'credential', + status: 'deleted', + location: 'local', + conflict: false, + updatedAt: '', + pushed: true, + }, + { + file: 'variable_stubs.json', + id: 'variables', + name: 'variables', + type: 'variables', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: '', + pushed: true, + }, + { + file: 'workflows/BpFS26gViuGqrIVP.json', + id: 'BpFS26gViuGqrIVP', + name: 'My workflow 5', + type: 'workflow', + status: 'modified', + location: 'remote', + conflict: true, + pushed: true, + updatedAt: '2023-07-10T10:10:59.000Z', + }, + { + file: 'workflows/BpFS26gViuGqrIVP.json', + id: 'BpFS26gViuGqrIVP', + name: 'My workflow 5', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: true, + updatedAt: '2023-07-10T10:10:59.000Z', + }, + { + file: 'workflows/dAU6dNthm4TR3gXx.json', + id: 'dAU6dNthm4TR3gXx', + name: 'My workflow 7', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + pushed: true, + updatedAt: '2023-07-10T10:02:45.186Z', + }, + { + file: 'workflows/haQetoXq9GxHSkft.json', + id: 'haQetoXq9GxHSkft', + name: 'My workflow 6', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + updatedAt: '2023-07-10T10:02:39.276Z', + }, +]; + +const pullResult: SourceControlledFile[] = [ + { + file: 'credential_stubs/kkookWGIeey9K4Kt.json', + id: 'kkookWGIeey9K4Kt', + name: '(deleted)', + type: 'credential', + status: 'deleted', + location: 'local', + conflict: false, + updatedAt: '', + }, + { + file: 'credential_stubs/abcdeWGIeey9K4aa.json', + id: 'abcdeWGIeey9K4aa', + name: 'modfied credential', + type: 'credential', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: '', + }, + { + file: 'workflows/BpFS26gViuGqrIVP.json', + id: 'BpFS26gViuGqrIVP', + name: '(deleted)', + type: 'workflow', + status: 'deleted', + location: 'local', + conflict: false, + updatedAt: '', + }, + { + file: 'variable_stubs.json', + id: 'variables', + name: 'variables', + type: 'variables', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: '', + }, + { + file: 'workflows/dAU6dNthm4TR3gXx.json', + id: 'dAU6dNthm4TR3gXx', + name: 'My workflow 7', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + updatedAt: '2023-07-10T10:02:45.186Z', + }, + { + file: 'workflows/haQetoXq9GxHSkft.json', + id: 'haQetoXq9GxHSkft', + name: 'My workflow 6', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: '2023-07-10T10:02:39.276Z', + }, +]; + +beforeAll(async () => { + LoggerProxy.init(getLogger()); + Container.get(License).isSourceControlLicensed = () => true; + Container.get(SourceControlPreferencesService).getPreferences = () => ({ + branchName: 'main', + connected: true, + repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git', + branchReadOnly: false, + branchColor: '#5296D6', + publicKey: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBSz2nMZAiUBWe6n89aWd5x9QMcIOaznVW3fpuCYC4L n8n deploy key', + }); +}); + +describe('Source Control', () => { + it('should generate an SSH key pair', async () => { + const keyPair = await generateSshKeyPair(); + expect(keyPair.privateKey).toBeTruthy(); + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toBeTruthy(); + expect(keyPair.publicKey).toContain('ssh-ed25519'); + }); + + it('should check for git and ssh folders and create them if required', async () => { + const userFolder = UserSettings.getUserN8nFolderPath(); + const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER); + const gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); + const sshKeyName = path.join(sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); + let hasThrown = false; + try { + accessSync(sshFolder, fsConstants.F_OK); + } catch (error) { + hasThrown = true; + } + expect(hasThrown).toBeTruthy(); + hasThrown = false; + try { + accessSync(gitFolder, fsConstants.F_OK); + } catch (error) { + hasThrown = true; + } + expect(hasThrown).toBeTruthy(); + // create missing folders + expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(false); + // find folders this time + expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(true); + expect(accessSync(sshFolder, fsConstants.F_OK)).toBeUndefined(); + expect(accessSync(gitFolder, fsConstants.F_OK)).toBeUndefined(); + }); + + it('should check if source control is licensed', async () => { + expect(Container.get(License).isSourceControlLicensed()).toBe(true); + }); + + it('should get repo type from url', async () => { + expect(getRepoType('git@github.com:n8ntest/n8n_testrepo.git')).toBe('github'); + expect(getRepoType('git@gitlab.com:n8ntest/n8n_testrepo.git')).toBe('gitlab'); + expect(getRepoType('git@mygitea.io:n8ntest/n8n_testrepo.git')).toBe('other'); + }); + + it('should get tracking information from pre-push results', () => { + const trackingResult = getTrackingInformationFromPrePushResult(pushResult); + expect(trackingResult).toEqual({ + workflows_eligible: 3, + workflows_eligible_with_conflicts: 1, + creds_eligible: 1, + creds_eligible_with_conflicts: 0, + variables_eligible: 1, + }); + }); + + it('should get tracking information from post-push results', () => { + const trackingResult = getTrackingInformationFromPostPushResult(pushResult); + expect(trackingResult).toEqual({ + workflows_pushed: 2, + workflows_eligible: 3, + creds_pushed: 1, + variables_pushed: 1, + }); + }); + + it('should get tracking information from pull results', () => { + const trackingResult = getTrackingInformationFromPullResult(pullResult); + expect(trackingResult).toEqual({ + cred_conflicts: 1, + workflow_conflicts: 1, + workflow_updates: 3, + }); + }); + + it('should class validate correct preferences', async () => { + const validPreferences = { + branchName: 'main', + repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git', + branchReadOnly: false, + branchColor: '#5296D6', + }; + const validationResult = await Container.get( + SourceControlPreferencesService, + ).validateSourceControlPreferences(validPreferences); + expect(validationResult).toBeTruthy(); + }); +}); diff --git a/packages/cli/test/unit/VersionControl.test.ts b/packages/cli/test/unit/VersionControl.test.ts deleted file mode 100644 index 52f1da05d2279..0000000000000 --- a/packages/cli/test/unit/VersionControl.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { generateSshKeyPair } from '../../src/environments/sourceControl/sourceControlHelper.ee'; - -describe('Source Control', () => { - it('should generate an SSH key pair', () => { - const keyPair = generateSshKeyPair(); - expect(keyPair.privateKey).toBeTruthy(); - expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); - expect(keyPair.publicKey).toBeTruthy(); - expect(keyPair.publicKey).toContain('ssh-ed25519'); - }); -}); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 9a36b6284d6e3..5e17b2d1f440e 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1451,8 +1451,6 @@ export type SamlPreferencesExtractedData = { export type SourceControlPreferences = { connected: boolean; repositoryUrl: string; - authorName: string; - authorEmail: string; branchName: string; branches: string[]; branchReadOnly: boolean; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts index 445c76257ad78..6dc7f2f07cdc3 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts @@ -9,8 +9,6 @@ export function routesForSourceControl(server: Server) { const defaultSourceControlPreferences: SourceControlPreferences = { branchName: '', branches: [], - authorName: '', - authorEmail: '', repositoryUrl: '', branchReadOnly: false, branchColor: '#1d6acb', diff --git a/packages/editor-ui/src/api/sourceControl.ts b/packages/editor-ui/src/api/sourceControl.ts index 3ab78fe4b34e9..144839b156e8b 100644 --- a/packages/editor-ui/src/api/sourceControl.ts +++ b/packages/editor-ui/src/api/sourceControl.ts @@ -52,8 +52,13 @@ export const getStatus = async (context: IRestApiContext): Promise => { - return makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`); + return makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options); }; export const disconnect = async ( diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index ae5bdba06447f..55d45172a20d2 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -152,7 +152,7 @@ import BreakpointsObserver from '@/components/BreakpointsObserver.vue'; import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface'; import { saveAs } from 'file-saver'; -import { useTitleChange, useToast, useMessage, useLoadingService } from '@/composables'; +import { useTitleChange, useToast, useMessage } from '@/composables'; import type { MessageBoxInputData } from 'element-ui/types/message-box'; import { useUIStore, @@ -169,6 +169,7 @@ import { getWorkflowPermissions } from '@/permissions'; import { createEventBus } from 'n8n-design-system'; import { useCloudPlanStore } from '@/stores'; import { nodeViewEventBus } from '@/event-bus'; +import { genericHelpers } from '@/mixins/genericHelpers'; const hasChanged = (prev: string[], curr: string[]) => { if (prev.length !== curr.length) { @@ -181,7 +182,7 @@ const hasChanged = (prev: string[], curr: string[]) => { export default defineComponent({ name: 'WorkflowDetails', - mixins: [workflowHelpers], + mixins: [workflowHelpers, genericHelpers], components: { TagsContainer, PushConnectionTracker, @@ -199,10 +200,7 @@ export default defineComponent({ }, }, setup() { - const loadingService = useLoadingService(); - return { - loadingService, ...useTitleChange(), ...useToast(), ...useMessage(), @@ -247,6 +245,9 @@ export default defineComponent({ isDirty(): boolean { return this.uiStore.stateIsDirty; }, + readOnlyEnv(): boolean { + return this.sourceControlStore.preferences.branchReadOnly; + }, currentWorkflowTagIds(): string[] { return this.workflowsStore.workflowTags; }, @@ -318,7 +319,8 @@ export default defineComponent({ disabled: !this.sourceControlStore.isEnterpriseSourceControlEnabled || !this.onWorkflowPage || - this.onExecutionsTab, + this.onExecutionsTab || + this.readOnlyEnv, }); actions.push({ @@ -531,25 +533,20 @@ export default defineComponent({ break; } case WORKFLOW_MENU_ACTIONS.PUSH: { - this.loadingService.startLoading(); + this.startLoading(); try { await this.onSaveButtonClick(); const status = await this.sourceControlStore.getAggregatedStatus(); - const workflowStatus = status.filter( - (s) => - (s.id === this.currentWorkflowId && s.type === 'workflow') || s.type !== 'workflow', - ); this.uiStore.openModalWithData({ name: SOURCE_CONTROL_PUSH_MODAL_KEY, - data: { eventBus: this.eventBus, status: workflowStatus }, + data: { eventBus: this.eventBus, status }, }); } catch (error) { this.showError(error, this.$locale.baseText('error')); } finally { - this.loadingService.stopLoading(); - this.loadingService.setLoadingText(this.$locale.baseText('genericHelpers.loading')); + this.stopLoading(); } break; diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue index 8a8c9016dff2d..e04e64907bb70 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue @@ -1,10 +1,12 @@ diff --git a/packages/editor-ui/src/views/SettingsSourceControl.vue b/packages/editor-ui/src/views/SettingsSourceControl.vue index 2d2c70f195ea8..21b1faf890adb 100644 --- a/packages/editor-ui/src/views/SettingsSourceControl.vue +++ b/packages/editor-ui/src/views/SettingsSourceControl.vue @@ -1,7 +1,7 @@