mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Export git connection projects to repository folder (no-changelog) (#36719)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4892a4a1a
commit
50954e34cd
|
|
@ -58,6 +58,28 @@ export const gitConnectionPublicSchema = z.object({
|
|||
|
||||
export class GitConnectionPublicDto extends Z.class(gitConnectionPublicSchema.shape) {}
|
||||
|
||||
/**
|
||||
* Per-entity counts of what actually landed in the exported package (after folder
|
||||
* bundling and auto-inclusion), surfaced by a push so the caller knows what was
|
||||
* written to the working copy.
|
||||
*/
|
||||
export const gitConnectionExportCountsSchema = z.object({
|
||||
workflows: z.number().int().nonnegative(),
|
||||
folders: z.number().int().nonnegative(),
|
||||
credentials: z.number().int().nonnegative(),
|
||||
dataTables: z.number().int().nonnegative(),
|
||||
variables: z.number().int().nonnegative(),
|
||||
tags: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** Outcome of a push: which connection, and per-entity counts of what was exported to its working copy. */
|
||||
export const gitConnectionPushResultSchema = z.object({
|
||||
connectionId: z.string(),
|
||||
counts: gitConnectionExportCountsSchema,
|
||||
});
|
||||
|
||||
export class GitConnectionPushResultDto extends Z.class(gitConnectionPushResultSchema.shape) {}
|
||||
|
||||
export const gitConnectionSummarySchema = gitConnectionPublicSchema.omit({ publicKey: true });
|
||||
|
||||
export class GitConnectionListPublicDto extends Z.class({
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export {
|
|||
GitConnectionProjectListPublicDto,
|
||||
GitConnectionProjectPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
GitConnectionPushResultDto,
|
||||
ListGitConnectionsQueryDto,
|
||||
UpdateGitConnectionDto,
|
||||
gitConnectionTypeSchema,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@
|
|||
"config": {
|
||||
"description": "View and update CLI configuration"
|
||||
},
|
||||
"git-connections": {
|
||||
"description": "Create and manage Git repository connections"
|
||||
},
|
||||
"source-control": {
|
||||
"description": "Pull changes from the configured source control provider"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,22 @@ describe('N8nClient packages', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('pushGitConnectionProjects', () => {
|
||||
it('POSTs to the push endpoint and returns the connection id and export counts', async () => {
|
||||
const response = {
|
||||
connectionId: 'connection-id',
|
||||
counts: { workflows: 0, folders: 0, credentials: 0, dataTables: 0, variables: 0, tags: 0 },
|
||||
};
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, response));
|
||||
|
||||
await expect(client.pushGitConnectionProjects('connection-id')).resolves.toEqual(response);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://n8n.example.com/api/v1/git-connections/connection-id/push');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportPackage', () => {
|
||||
it('posts the workflow IDs as JSON and returns the archive bytes', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1, 2, 3])));
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ export interface ExportPackageResult {
|
|||
counts?: ExportPackageCounts;
|
||||
}
|
||||
|
||||
/** Outcome of pushing a Git connection's projects to its working copy. */
|
||||
export type PushGitConnectionResult = {
|
||||
connectionId: string;
|
||||
counts: ExportPackageCounts;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
|
|
@ -248,6 +254,10 @@ export class N8nClient {
|
|||
return await this.del<undefined>(`/git-connections/${id}`);
|
||||
}
|
||||
|
||||
async pushGitConnectionProjects(id: string) {
|
||||
return await this.post<PushGitConnectionResult>(`/git-connections/${id}/push`);
|
||||
}
|
||||
|
||||
async listGitConnectionProjects(id: string) {
|
||||
return await this.get<{ projectIds: string[] }>(`/git-connections/${id}/projects`);
|
||||
}
|
||||
|
|
|
|||
24
packages/@n8n/cli/src/commands/git-connections/push.ts
Normal file
24
packages/@n8n/cli/src/commands/git-connections/push.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Args } from '@oclif/core';
|
||||
|
||||
import { BaseCommand } from '../../base-command';
|
||||
|
||||
export default class GitConnectionsPush extends BaseCommand {
|
||||
static override description =
|
||||
'Export linked projects to a Git connection working copy (work in progress; does not commit or push)';
|
||||
static override args = {
|
||||
id: Args.string({ description: 'ID of the Git connection', required: true }),
|
||||
};
|
||||
static override flags = { ...BaseCommand.baseFlags };
|
||||
|
||||
async run() {
|
||||
const { args, flags } = await this.parse(GitConnectionsPush);
|
||||
await this.execute(async () => {
|
||||
const result = await this.getClient(flags).pushGitConnectionProjects(args.id);
|
||||
this.succeed(
|
||||
`Projects exported to the local working copy for Git connection ${args.id}. This work-in-progress command did not commit or push changes to the selected branch.`,
|
||||
flags,
|
||||
result,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import GitConnectionsDisconnect from './commands/git-connections/disconnect';
|
|||
import GitConnectionsGet from './commands/git-connections/get';
|
||||
import GitConnectionsList from './commands/git-connections/list';
|
||||
import GitConnectionsListProjects from './commands/git-connections/list-projects';
|
||||
import GitConnectionsPush from './commands/git-connections/push';
|
||||
import GitConnectionsRemoveProject from './commands/git-connections/remove-project';
|
||||
import GitConnectionsUpdate from './commands/git-connections/update';
|
||||
import Login from './commands/login';
|
||||
|
|
@ -113,6 +114,8 @@ export const commands = {
|
|||
'tag:update': TagUpdate,
|
||||
'tag:delete': TagDelete,
|
||||
|
||||
'git-connections:push': GitConnectionsPush,
|
||||
|
||||
'project:list': ProjectList,
|
||||
'project:get': ProjectGet,
|
||||
'project:create': ProjectCreate,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
|||
"gitConnection:delete",
|
||||
"gitConnection:list",
|
||||
"gitConnection:clone",
|
||||
"gitConnection:push",
|
||||
"gitConnection:manageProjects",
|
||||
"tag:create",
|
||||
"tag:read",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const RESOURCES = {
|
|||
securityAudit: ['generate'] as const,
|
||||
securitySettings: ['manage'] as const,
|
||||
sourceControl: ['pull', 'push', 'manage'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'manageProjects'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects'] as const,
|
||||
tag: [...DEFAULT_OPERATIONS] as const,
|
||||
user: [
|
||||
'resetPassword',
|
||||
|
|
@ -108,7 +108,7 @@ export const API_KEY_RESOURCES = {
|
|||
credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const,
|
||||
eventBusDestination: ['test', 'create', 'read', 'update', 'delete', 'list'] as const,
|
||||
sourceControl: ['pull'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'manageProjects'] as const,
|
||||
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
executionTags: ['update', 'list'] as const,
|
||||
communityPackage: ['install', 'uninstall', 'update', 'list'] as const,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
|||
'gitConnection:delete',
|
||||
'gitConnection:list',
|
||||
'gitConnection:clone',
|
||||
'gitConnection:push',
|
||||
'gitConnection:manageProjects',
|
||||
'securityAudit:generate',
|
||||
'securitySettings:manage',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'gitConnection:delete',
|
||||
'gitConnection:list',
|
||||
'gitConnection:clone',
|
||||
'gitConnection:push',
|
||||
'gitConnection:manageProjects',
|
||||
'tag:create',
|
||||
'tag:read',
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
|
|||
displayName: 'Clone Git Connection',
|
||||
description: 'Allows cloning and removing the local working copy of a Git connection.',
|
||||
},
|
||||
'gitConnection:push': {
|
||||
displayName: 'Push Git Connection',
|
||||
description: 'Allows pushing all projects linked to a Git connection to its remote repository.',
|
||||
},
|
||||
'gitConnection:manageProjects': {
|
||||
displayName: 'Manage Git Connection Projects',
|
||||
description: 'Allows adding projects to and removing projects from a Git connection.',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { CreateGitConnectionDto, UpdateGitConnectionDto } from '@n8n/api-types';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { ProjectRepository, User } from '@n8n/db';
|
||||
import type { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { MockedFunction } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
|
|
@ -8,6 +12,11 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import {
|
||||
MissingWorkflowDependencyPolicy,
|
||||
WorkflowVersionPolicy,
|
||||
} from '@/modules/n8n-packages/n8n-packages.types';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
|
||||
import type { GitConnection } from '../database/entities/git-connection.entity';
|
||||
|
|
@ -24,16 +33,21 @@ describe('GitConnectionsService (credential state machine)', () => {
|
|||
const gitConnectionProjectRepository = mock<GitConnectionProjectRepository>();
|
||||
const projectRepository = mock<ProjectRepository>();
|
||||
const gitService = mock<GitConnectionsGitService>();
|
||||
const n8nPackagesService = mock<N8nPackagesService>();
|
||||
const cipher = mock<Cipher>();
|
||||
const instanceSettings = mock<InstanceSettings>({ n8nFolder: '/tmp/n8n' });
|
||||
const logger = mock<Logger>();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
const service = new GitConnectionsService(
|
||||
repository,
|
||||
gitConnectionProjectRepository,
|
||||
projectRepository,
|
||||
gitService,
|
||||
n8nPackagesService,
|
||||
cipher,
|
||||
instanceSettings,
|
||||
logger,
|
||||
);
|
||||
|
||||
const baseEntity = () => ({
|
||||
|
|
@ -41,6 +55,7 @@ describe('GitConnectionsService (credential state machine)', () => {
|
|||
name: 'c',
|
||||
repositoryUrl: 'git@github.com:o/r.git',
|
||||
branchName: 'main',
|
||||
baseCommit: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
|
@ -212,6 +227,140 @@ describe('GitConnectionsService (credential state machine)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('push export', () => {
|
||||
let n8nFolder: string;
|
||||
let exportService: GitConnectionsService;
|
||||
const actor = mock<User>({ id: 'actor' });
|
||||
|
||||
beforeEach(async () => {
|
||||
n8nFolder = await mkdtemp(path.join(tmpdir(), 'n8n-git-connection-export-'));
|
||||
exportService = new GitConnectionsService(
|
||||
repository,
|
||||
gitConnectionProjectRepository,
|
||||
projectRepository,
|
||||
gitService,
|
||||
n8nPackagesService,
|
||||
cipher,
|
||||
mock<InstanceSettings>({ n8nFolder }),
|
||||
logger,
|
||||
);
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
gitConnectionProjectRepository.findProjectIdsByConnection.mockResolvedValue([
|
||||
'project-a',
|
||||
'project-b',
|
||||
]);
|
||||
n8nPackagesService.exportPackageToDirectory.mockImplementation(
|
||||
async (_request, { targetDir }) => {
|
||||
await mkdir(path.join(targetDir, 'projects', 'alpha'), { recursive: true });
|
||||
await writeFile(path.join(targetDir, 'manifest.json'), '{"projects":[]}');
|
||||
await writeFile(path.join(targetDir, 'projects', 'alpha', 'project.json'), '{}');
|
||||
return {
|
||||
counts: {
|
||||
workflows: 0,
|
||||
folders: 0,
|
||||
credentials: 0,
|
||||
dataTables: 0,
|
||||
variables: 0,
|
||||
tags: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(n8nFolder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exports all linked projects into the n8n-export subfolder, leaving the root untouched', async () => {
|
||||
const repositoryFolder = path.join(n8nFolder, 'git-connections', '1', 'repository');
|
||||
const exportFolder = path.join(repositoryFolder, 'n8n-export');
|
||||
await mkdir(path.join(repositoryFolder, '.git'), { recursive: true });
|
||||
await writeFile(path.join(repositoryFolder, '.git', 'HEAD'), 'ref: refs/heads/main');
|
||||
// A file the user keeps at the repository root must survive the export.
|
||||
await writeFile(path.join(repositoryFolder, 'README.md'), '# my repo');
|
||||
// A stale export from a project that is no longer linked must be removed.
|
||||
await mkdir(exportFolder, { recursive: true });
|
||||
await writeFile(path.join(exportFolder, 'stale.json'), '{}');
|
||||
|
||||
const result = await exportService.push('1', actor);
|
||||
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
|
||||
|
||||
expect(gitConnectionProjectRepository.findProjectIdsByConnection).toHaveBeenCalledWith('1');
|
||||
expect(n8nPackagesService.exportPackageToDirectory).toHaveBeenCalledWith(
|
||||
{
|
||||
user: actor,
|
||||
projectIds: ['project-a', 'project-b'],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.Fail,
|
||||
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
|
||||
},
|
||||
{ targetDir: stagingFolder },
|
||||
);
|
||||
expect(path.dirname(stagingFolder)).toBe(repositoryFolder);
|
||||
expect(path.basename(stagingFolder)).toMatch(/^\.n8n-export-/);
|
||||
expect(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')).toBe(
|
||||
'{"projects":[]}',
|
||||
);
|
||||
// `.git` and user files at the root are untouched.
|
||||
expect(await readFile(path.join(repositoryFolder, '.git', 'HEAD'), 'utf-8')).toBe(
|
||||
'ref: refs/heads/main',
|
||||
);
|
||||
expect(await readFile(path.join(repositoryFolder, 'README.md'), 'utf-8')).toBe('# my repo');
|
||||
expect(result).toEqual({
|
||||
connectionId: '1',
|
||||
counts: {
|
||||
workflows: 0,
|
||||
folders: 0,
|
||||
credentials: 0,
|
||||
dataTables: 0,
|
||||
variables: 0,
|
||||
tags: 0,
|
||||
},
|
||||
});
|
||||
// The stale export is gone; only the freshly written package remains.
|
||||
await expect(stat(path.join(exportFolder, 'stale.json'))).rejects.toThrow();
|
||||
await expect(stat(stagingFolder)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the previous export and repository root intact when export fails', async () => {
|
||||
const repositoryFolder = path.join(n8nFolder, 'git-connections', '1', 'repository');
|
||||
const exportFolder = path.join(repositoryFolder, 'n8n-export');
|
||||
await mkdir(path.join(repositoryFolder, '.git'), { recursive: true });
|
||||
await writeFile(path.join(repositoryFolder, '.git', 'HEAD'), 'ref: refs/heads/main');
|
||||
await writeFile(path.join(repositoryFolder, 'README.md'), '# my repo');
|
||||
await mkdir(exportFolder, { recursive: true });
|
||||
await writeFile(path.join(exportFolder, 'manifest.json'), '{"previous":true}');
|
||||
n8nPackagesService.exportPackageToDirectory.mockRejectedValueOnce(
|
||||
new BadRequestError('A linked project dependency is missing'),
|
||||
);
|
||||
|
||||
await expect(exportService.push('1', actor)).rejects.toThrow(BadRequestError);
|
||||
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
|
||||
|
||||
expect(await readFile(path.join(repositoryFolder, '.git', 'HEAD'), 'utf-8')).toBe(
|
||||
'ref: refs/heads/main',
|
||||
);
|
||||
expect(await readFile(path.join(repositoryFolder, 'README.md'), 'utf-8')).toBe('# my repo');
|
||||
expect(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')).toBe(
|
||||
'{"previous":true}',
|
||||
);
|
||||
await expect(stat(stagingFolder)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not query projects or write files for a missing connection', async () => {
|
||||
repository.findOneBy.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(exportService.push('missing', actor)).rejects.toThrow(
|
||||
'Git connection not found',
|
||||
);
|
||||
expect(gitConnectionProjectRepository.findProjectIdsByConnection).not.toHaveBeenCalled();
|
||||
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding and removing projects', () => {
|
||||
const teamProject = { id: 'p1', type: 'team' };
|
||||
const personalProject = { id: 'p1', type: 'personal' };
|
||||
|
|
|
|||
|
|
@ -3,18 +3,26 @@ import {
|
|||
GitConnectionProjectListPublicDto,
|
||||
GitConnectionProjectPublicDto,
|
||||
type GitConnectionPublicDto,
|
||||
type GitConnectionPushResultDto,
|
||||
type UpdateGitConnectionDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { ProjectRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import {
|
||||
MissingWorkflowDependencyPolicy,
|
||||
WorkflowVersionPolicy,
|
||||
} from '@/modules/n8n-packages/n8n-packages.types';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
|
||||
import { GitConnectionProject } from './database/entities/git-connection-project.entity';
|
||||
|
|
@ -23,6 +31,13 @@ import { GitConnectionProjectRepository } from './database/repositories/git-conn
|
|||
import { GitConnectionRepository } from './database/repositories/git-connection.repository';
|
||||
import { GitConnectionsGitService } from './git-connections-git.service';
|
||||
|
||||
/**
|
||||
* Subfolder of the working copy that holds the n8n-managed export. Keeping it
|
||||
* separate from the repository root leaves `.git` and any files the user commits
|
||||
* at the root untouched, and scopes the export's cleanup to a single directory.
|
||||
*/
|
||||
const EXPORT_SUBFOLDER = 'n8n-export';
|
||||
|
||||
type ManageProjectLinkOptions = {
|
||||
user: User;
|
||||
connectionId: string;
|
||||
|
|
@ -36,9 +51,13 @@ export class GitConnectionsService {
|
|||
private readonly gitConnectionProjectRepository: GitConnectionProjectRepository,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly gitService: GitConnectionsGitService,
|
||||
private readonly n8nPackagesService: N8nPackagesService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
) {}
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.logger = this.logger.scoped('git-connections');
|
||||
}
|
||||
|
||||
async create(input: CreateGitConnectionDto) {
|
||||
this.gitService.validateRepositoryUrl(input.repositoryUrl, input.connectionType);
|
||||
|
|
@ -128,6 +147,58 @@ export class GitConnectionsService {
|
|||
await this.repository.remove(connection);
|
||||
}
|
||||
|
||||
async push(connectionId: string, actor: User): Promise<GitConnectionPushResultDto> {
|
||||
return await this.exportProjectsToRepository(connectionId, actor);
|
||||
}
|
||||
|
||||
private async exportProjectsToRepository(
|
||||
connectionId: string,
|
||||
actor: User,
|
||||
): Promise<GitConnectionPushResultDto> {
|
||||
// Validates the connection exists (throws NotFound otherwise) before any export work.
|
||||
await this.getEntity(connectionId);
|
||||
const projectIds =
|
||||
await this.gitConnectionProjectRepository.findProjectIdsByConnection(connectionId);
|
||||
const repositoryFolder = path.join(this.rootFolder(connectionId), 'repository');
|
||||
const exportFolder = path.join(repositoryFolder, EXPORT_SUBFOLDER);
|
||||
|
||||
this.logger.info('Exporting projects to Git connection repository', {
|
||||
connectionId,
|
||||
projectCount: projectIds.length,
|
||||
});
|
||||
|
||||
await mkdir(repositoryFolder, { recursive: true });
|
||||
const stagingFolder = await mkdtemp(path.join(repositoryFolder, `.${EXPORT_SUBFOLDER}-`));
|
||||
|
||||
try {
|
||||
const exportResult = await this.n8nPackagesService.exportPackageToDirectory(
|
||||
{
|
||||
user: actor,
|
||||
projectIds,
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.Fail,
|
||||
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
|
||||
},
|
||||
{ targetDir: stagingFolder },
|
||||
);
|
||||
|
||||
// Replace the managed export only after the new package is complete.
|
||||
await rm(exportFolder, { recursive: true, force: true });
|
||||
await rename(stagingFolder, exportFolder);
|
||||
|
||||
// TODO: commit the working copy and push to the remote, then surface the
|
||||
// git operation result here.
|
||||
return {
|
||||
connectionId,
|
||||
counts: exportResult.counts,
|
||||
};
|
||||
} finally {
|
||||
await rm(stagingFolder, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async addProject({
|
||||
user,
|
||||
connectionId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import { FORMAT_VERSION } from '../spec/constants';
|
||||
|
||||
let service: N8nPackagesService;
|
||||
let owner: User;
|
||||
let targetDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['n8n-packages']);
|
||||
await testDb.init();
|
||||
service = Container.get(N8nPackagesService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'Folder',
|
||||
'WorkflowEntity',
|
||||
'SharedWorkflow',
|
||||
'ProjectRelation',
|
||||
'Project',
|
||||
]);
|
||||
owner = await createOwner();
|
||||
targetDir = await mkdtemp(path.join(tmpdir(), 'n8n-export-dir-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function readJson(...segments: string[]) {
|
||||
return JSON.parse(await readFile(path.join(targetDir, ...segments), 'utf-8'));
|
||||
}
|
||||
|
||||
describe('exportPackageToDirectory', () => {
|
||||
it('writes the unzipped package straight into the target directory', async () => {
|
||||
const project = await createTeamProject('Alpha Project', owner);
|
||||
await createWorkflow({ name: 'WF One', nodes: [], connections: {} }, project);
|
||||
|
||||
const result = await service.exportPackageToDirectory(
|
||||
{ user: owner, projectIds: [project.id] },
|
||||
{ targetDir },
|
||||
);
|
||||
|
||||
const manifest = await readJson('manifest.json');
|
||||
expect(manifest).toMatchObject({ packageFormatVersion: FORMAT_VERSION });
|
||||
expect(manifest.projects).toEqual([
|
||||
{ id: project.id, name: 'Alpha Project', target: 'projects/alpha-project' },
|
||||
]);
|
||||
expect(await readJson('projects/alpha-project/project.json')).toEqual({
|
||||
id: project.id,
|
||||
name: 'Alpha Project',
|
||||
});
|
||||
expect(result.counts.workflows).toBe(1);
|
||||
});
|
||||
|
||||
it('writes multiple projects into one combined package', async () => {
|
||||
const alpha = await createTeamProject('Alpha Project', owner);
|
||||
const beta = await createTeamProject('Beta Project', owner);
|
||||
await createWorkflow({ name: 'Alpha Workflow', nodes: [], connections: {} }, alpha);
|
||||
await createWorkflow({ name: 'Beta Workflow', nodes: [], connections: {} }, beta);
|
||||
|
||||
const result = await service.exportPackageToDirectory(
|
||||
{ user: owner, projectIds: [alpha.id, beta.id] },
|
||||
{ targetDir },
|
||||
);
|
||||
|
||||
const manifest = await readJson('manifest.json');
|
||||
expect(manifest.projects).toEqual([
|
||||
{ id: alpha.id, name: 'Alpha Project', target: 'projects/alpha-project' },
|
||||
{ id: beta.id, name: 'Beta Project', target: 'projects/beta-project' },
|
||||
]);
|
||||
expect(await readdir(targetDir)).toEqual(expect.arrayContaining(['manifest.json', 'projects']));
|
||||
expect(result.counts.workflows).toBe(2);
|
||||
});
|
||||
|
||||
it('does not emit the user package-export event', async () => {
|
||||
const project = await createTeamProject('Alpha Project', owner);
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
|
||||
await service.exportPackageToDirectory(
|
||||
{ user: owner, projectIds: [project.id] },
|
||||
{ targetDir },
|
||||
);
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('n8n-package-exported', expect.anything());
|
||||
emitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -83,8 +83,8 @@ export class CredentialExporter {
|
|||
);
|
||||
const baseDir = this.resolveBaseDir(credential, request.projectTargetsById);
|
||||
const target = allocatorFor(baseDir).allocate(name);
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(
|
||||
await request.writer.writeDirectory(target);
|
||||
await request.writer.writeFile(
|
||||
`${target}/credential.json`,
|
||||
JSON.stringify(this.credentialSerializer.serialize(credential, { data }), null, '\t'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export class DataTableExporter {
|
|||
const baseDir = this.resolveBaseDir(dataTable, request.projectTargetsById);
|
||||
const target = allocatorFor(baseDir).allocate(dataTable.name);
|
||||
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(
|
||||
await request.writer.writeDirectory(target);
|
||||
await request.writer.writeFile(
|
||||
`${target}/data-table.json`,
|
||||
JSON.stringify(this.dataTableSerializer.serialize(dataTable), null, '\t'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export class FolderExporter {
|
|||
): Promise<FolderExportResult> {
|
||||
const { childrenByParent, workflowIdsByFolder, request } = context;
|
||||
|
||||
this.exportFolderShell(folder, target, effectiveParentId, request.writer);
|
||||
await this.exportFolderShell(folder, target, effectiveParentId, request.writer);
|
||||
|
||||
const workflowIds = workflowIdsByFolder.get(folder.id) ?? [];
|
||||
const contained = await this.exportContainedWorkflows(workflowIds, target, request);
|
||||
|
|
@ -160,15 +160,15 @@ export class FolderExporter {
|
|||
return this.mergeFolderExportResults([own, descendants]);
|
||||
}
|
||||
|
||||
private exportFolderShell(
|
||||
private async exportFolderShell(
|
||||
folder: Folder,
|
||||
target: string,
|
||||
effectiveParentId: string | null,
|
||||
writer: PackageWriter,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const serialized = this.folderSerializer.serialize(folder, effectiveParentId);
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
|
||||
}
|
||||
|
||||
private async exportContainedWorkflows(
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export class ProjectExporter {
|
|||
target: string,
|
||||
request: ProjectExportRequest,
|
||||
): Promise<ProjectExportResult> {
|
||||
this.exportProjectShell(project, target, request.writer);
|
||||
await this.exportProjectShell(project, target, request.writer);
|
||||
const folders = await this.exportProjectFolders(project.id, target, request);
|
||||
const rootWorkflows = await this.exportProjectRootWorkflows(project.id, target, request);
|
||||
|
||||
|
|
@ -88,10 +88,14 @@ export class ProjectExporter {
|
|||
};
|
||||
}
|
||||
|
||||
private exportProjectShell(project: Project, target: string, writer: PackageWriter): void {
|
||||
private async exportProjectShell(
|
||||
project: Project,
|
||||
target: string,
|
||||
writer: PackageWriter,
|
||||
): Promise<void> {
|
||||
const serialized = this.projectSerializer.serialize(project);
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
|
||||
}
|
||||
|
||||
private async exportProjectFolders(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface TagExportResult {
|
|||
|
||||
@Service()
|
||||
export class TagExporter {
|
||||
export(request: TagExportRequest): TagExportResult {
|
||||
async export(request: TagExportRequest): Promise<TagExportResult> {
|
||||
const requirementsByTagId = new Map<string, PackageTagRequirement>();
|
||||
|
||||
for (const { workflowId, tag } of request.usages) {
|
||||
|
|
@ -60,8 +60,8 @@ export class TagExporter {
|
|||
for (const { id, name } of requirements) {
|
||||
const tagDirectory = allocator.allocate(name);
|
||||
const serializedTag = serializedTagSchema.parse(serializePayload({ id, name }));
|
||||
request.writer.writeDirectory(tagDirectory);
|
||||
request.writer.writeFile(
|
||||
await request.writer.writeDirectory(tagDirectory);
|
||||
await request.writer.writeFile(
|
||||
`${tagDirectory}/tag.json`,
|
||||
JSON.stringify(serializedTag, null, '\t'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ export class VariableExporter {
|
|||
|
||||
const baseDir = this.resolveBaseDir(variable, request.projectTargetsById);
|
||||
const target = allocatorFor(baseDir).allocate(variable.key);
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(
|
||||
await request.writer.writeDirectory(target);
|
||||
await request.writer.writeFile(
|
||||
`${target}/variable.json`,
|
||||
JSON.stringify(
|
||||
this.serializeOrBlock(variable, request.includeVariableValues),
|
||||
|
|
|
|||
|
|
@ -76,12 +76,12 @@ function emptyRequest(writer: CapturingWriter, workflows: AutoIncludedWorkflow[]
|
|||
}
|
||||
|
||||
describe('AutoIncludedWorkflowExporter', () => {
|
||||
it('writes a top-level workflow under workflows/', () => {
|
||||
it('writes a top-level workflow under workflows/', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-triage', name: 'Triage' });
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [includedWorkflow({ workflow, placement: 'top-level' })]),
|
||||
);
|
||||
|
||||
|
|
@ -91,12 +91,12 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(writer.files.map((f) => f.path)).toContain('workflows/triage/workflow.json');
|
||||
});
|
||||
|
||||
it('skips a workflow already present in the existing workflow entries', () => {
|
||||
it('skips a workflow already present in the existing workflow entries', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-dup', name: 'Already Here' });
|
||||
|
||||
const result = exporter.export({
|
||||
const result = await exporter.export({
|
||||
...emptyRequest(writer, [includedWorkflow({ workflow })]),
|
||||
existingWorkflowEntries: [
|
||||
{ id: 'wf-dup', name: 'Already Here', target: 'workflows/already-here' },
|
||||
|
|
@ -107,12 +107,12 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(writer.files).toEqual([]);
|
||||
});
|
||||
|
||||
it('disambiguates a workflow target that collides with an existing entry', () => {
|
||||
it('disambiguates a workflow target that collides with an existing entry', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-new', name: 'Same Name' });
|
||||
|
||||
const result = exporter.export({
|
||||
const result = await exporter.export({
|
||||
...emptyRequest(writer, [includedWorkflow({ workflow })]),
|
||||
// a different workflow already occupies workflows/same-name
|
||||
existingWorkflowEntries: [
|
||||
|
|
@ -123,13 +123,13 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(result.workflowEntries[0].target).toBe('workflows/same-name-2');
|
||||
});
|
||||
|
||||
it('places a folder workflow under its serialized folder chain', () => {
|
||||
it('places a folder workflow under its serialized folder chain', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-nested', name: 'Nested' });
|
||||
const chain = [makeFolder('f-root', 'Root'), makeFolder('f-child', 'Child')];
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [
|
||||
includedWorkflow({ workflow, placement: 'folder', folderChain: chain }),
|
||||
]),
|
||||
|
|
@ -147,13 +147,13 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(childFolder.parentFolderId).toBe('f-root');
|
||||
});
|
||||
|
||||
it('reuses an existing folder entry instead of recreating it', () => {
|
||||
it('reuses an existing folder entry instead of recreating it', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-nested', name: 'Nested' });
|
||||
const chain = [makeFolder('f-root', 'Root')];
|
||||
|
||||
const result = exporter.export({
|
||||
const result = await exporter.export({
|
||||
...emptyRequest(writer, [
|
||||
includedWorkflow({ workflow, placement: 'folder', folderChain: chain }),
|
||||
]),
|
||||
|
|
@ -165,13 +165,13 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(writer.files.some((f) => f.path === 'folders/root/folder.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a project shell for a project workflow and reports its target', () => {
|
||||
it('creates a project shell for a project workflow and reports its target', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-p', name: 'In Project' });
|
||||
const project = makeProject('proj-9', 'Marketing');
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [
|
||||
includedWorkflow({
|
||||
workflow,
|
||||
|
|
@ -190,14 +190,14 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(writer.files.some((f) => f.path === 'projects/marketing/project.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('nests a project workflow with a folder chain under the project folders/', () => {
|
||||
it('nests a project workflow with a folder chain under the project folders/', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-pf', name: 'Deep' });
|
||||
const project = makeProject('proj-9', 'Marketing');
|
||||
const chain = [makeFolder('f-a', 'Campaigns')];
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [
|
||||
includedWorkflow({
|
||||
workflow,
|
||||
|
|
@ -213,13 +213,13 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('reuses an existing project entry and preserves its target', () => {
|
||||
it('reuses an existing project entry and preserves its target', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-p', name: 'In Project' });
|
||||
const project = makeProject('proj-9', 'Marketing');
|
||||
|
||||
const result = exporter.export({
|
||||
const result = await exporter.export({
|
||||
...emptyRequest(writer, [
|
||||
includedWorkflow({
|
||||
workflow,
|
||||
|
|
@ -237,7 +237,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(writer.files.some((f) => f.path === 'projects/marketing/project.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts credential, data-table, and variable requirements from each workflow', () => {
|
||||
it('extracts credential, data-table, and variable requirements from each workflow', async () => {
|
||||
const credentialExtractor = mock<CredentialRequirementsExtractor>();
|
||||
credentialExtractor.extract.mockReturnValue([
|
||||
{
|
||||
|
|
@ -255,7 +255,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
const exporter = makeExporter(credentialExtractor, dataTableExtractor, variableExtractor);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const result = exporter.export(emptyRequest(writer, [includedWorkflow()]));
|
||||
const result = await exporter.export(emptyRequest(writer, [includedWorkflow()]));
|
||||
|
||||
expect(result.requirements.credentials).toEqual([
|
||||
{
|
||||
|
|
@ -271,7 +271,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('collects each workflow node list into requirements.nodeTypes', () => {
|
||||
it('collects each workflow node list into requirements.nodeTypes', async () => {
|
||||
const nodeA = {
|
||||
id: 'n1',
|
||||
name: 'HTTP',
|
||||
|
|
@ -285,7 +285,7 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [includedWorkflow({ workflow: a }), includedWorkflow({ workflow: b })]),
|
||||
);
|
||||
|
||||
|
|
@ -295,14 +295,14 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not extract requirements from a skipped (already-exported) workflow', () => {
|
||||
it('does not extract requirements from a skipped (already-exported) workflow', async () => {
|
||||
const credentialExtractor = mock<CredentialRequirementsExtractor>();
|
||||
credentialExtractor.extract.mockReturnValue([]);
|
||||
const exporter = makeExporter(credentialExtractor);
|
||||
const writer = new CapturingWriter();
|
||||
const workflow = makeWorkflow({ id: 'wf-dup', name: 'Already Here' });
|
||||
|
||||
const result = exporter.export({
|
||||
const result = await exporter.export({
|
||||
...emptyRequest(writer, [includedWorkflow({ workflow })]),
|
||||
existingWorkflowEntries: [
|
||||
{ id: 'wf-dup', name: 'Already Here', target: 'workflows/already-here' },
|
||||
|
|
@ -313,13 +313,13 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(result.requirements.nodeTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it('reserves the workflows/ segment so a child folder named "workflows" does not collide with its parent workflow directory', () => {
|
||||
it('reserves the workflows/ segment so a child folder named "workflows" does not collide with its parent workflow directory', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const root = makeFolder('f-root', 'Root');
|
||||
const wfFolder = makeFolder('f-wf', 'Workflows');
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [
|
||||
// A workflow placed directly in Root → folders/root/workflows/alpha
|
||||
includedWorkflow({
|
||||
|
|
@ -346,12 +346,12 @@ describe('AutoIncludedWorkflowExporter', () => {
|
|||
expect(nestedWorkflow?.target).toBe('folders/root/workflows-2/workflows/beta');
|
||||
});
|
||||
|
||||
it('shares one folder shell between two workflows in the same folder', () => {
|
||||
it('shares one folder shell between two workflows in the same folder', async () => {
|
||||
const exporter = makeExporter();
|
||||
const writer = new CapturingWriter();
|
||||
const chain = [makeFolder('f-root', 'Root')];
|
||||
|
||||
const result = exporter.export(
|
||||
const result = await exporter.export(
|
||||
emptyRequest(writer, [
|
||||
includedWorkflow({
|
||||
workflow: makeWorkflow({ id: 'wf-a', name: 'Alpha' }),
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ export class AutoIncludedWorkflowExporter {
|
|||
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
|
||||
) {}
|
||||
|
||||
export(request: AutoIncludedWorkflowExportRequest): AutoIncludedWorkflowExportResult {
|
||||
async export(
|
||||
request: AutoIncludedWorkflowExportRequest,
|
||||
): Promise<AutoIncludedWorkflowExportResult> {
|
||||
const allocators: ExportAllocators = {
|
||||
workflows: new Map(),
|
||||
folders: new Map(),
|
||||
|
|
@ -103,7 +105,7 @@ export class AutoIncludedWorkflowExporter {
|
|||
for (const included of request.workflows) {
|
||||
if (workflowEntriesById.has(included.workflow.id)) continue;
|
||||
|
||||
const baseDir = this.resolveWorkflowBaseDir({
|
||||
const baseDir = await this.resolveWorkflowBaseDir({
|
||||
included,
|
||||
writer: request.writer,
|
||||
folderEntriesById,
|
||||
|
|
@ -113,7 +115,7 @@ export class AutoIncludedWorkflowExporter {
|
|||
projectEntries,
|
||||
allocators,
|
||||
});
|
||||
const entry = this.writeWorkflow(
|
||||
const entry = await this.writeWorkflow(
|
||||
included.workflow,
|
||||
baseDir,
|
||||
request.writer,
|
||||
|
|
@ -141,7 +143,7 @@ export class AutoIncludedWorkflowExporter {
|
|||
};
|
||||
}
|
||||
|
||||
private resolveWorkflowBaseDir(options: {
|
||||
private async resolveWorkflowBaseDir(options: {
|
||||
included: AutoIncludedWorkflow;
|
||||
writer: PackageWriter;
|
||||
folderEntriesById: Map<string, ManifestEntry>;
|
||||
|
|
@ -150,10 +152,10 @@ export class AutoIncludedWorkflowExporter {
|
|||
folderEntries: ManifestEntry[];
|
||||
projectEntries: ManifestEntry[];
|
||||
allocators: ExportAllocators;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const { included } = options;
|
||||
if (included.placement === 'project') {
|
||||
const projectTarget = this.ensureProjectShell({
|
||||
const projectTarget = await this.ensureProjectShell({
|
||||
project: included.ownerProject,
|
||||
writer: options.writer,
|
||||
projectEntriesById: options.projectEntriesById,
|
||||
|
|
@ -166,7 +168,7 @@ export class AutoIncludedWorkflowExporter {
|
|||
return `${projectTarget}/workflows`;
|
||||
}
|
||||
|
||||
const folderTarget = this.ensureFolderChain({
|
||||
const folderTarget = await this.ensureFolderChain({
|
||||
chain: included.folderChain,
|
||||
baseDir: `${projectTarget}/folders`,
|
||||
writer: options.writer,
|
||||
|
|
@ -178,7 +180,7 @@ export class AutoIncludedWorkflowExporter {
|
|||
}
|
||||
|
||||
if (included.placement === 'folder' && included.folderChain.length > 0) {
|
||||
const folderTarget = this.ensureFolderChain({
|
||||
const folderTarget = await this.ensureFolderChain({
|
||||
chain: included.folderChain,
|
||||
baseDir: 'folders',
|
||||
writer: options.writer,
|
||||
|
|
@ -192,21 +194,24 @@ export class AutoIncludedWorkflowExporter {
|
|||
return 'workflows';
|
||||
}
|
||||
|
||||
private ensureProjectShell(options: {
|
||||
private async ensureProjectShell(options: {
|
||||
project: Project;
|
||||
writer: PackageWriter;
|
||||
projectEntriesById: Map<string, ManifestEntry>;
|
||||
projectTargetsById: Map<string, string>;
|
||||
projectEntries: ManifestEntry[];
|
||||
allocator: UniqueFilenameAllocator;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const existing = options.projectEntriesById.get(options.project.id);
|
||||
if (existing) return existing.target;
|
||||
|
||||
const target = options.allocator.allocate(options.project.name);
|
||||
const serialized = this.projectSerializer.serialize(options.project);
|
||||
options.writer.writeDirectory(target);
|
||||
options.writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await options.writer.writeDirectory(target);
|
||||
await options.writer.writeFile(
|
||||
`${target}/project.json`,
|
||||
JSON.stringify(serialized, null, '\t'),
|
||||
);
|
||||
|
||||
const entry = { id: options.project.id, name: options.project.name, target };
|
||||
options.projectEntries.push(entry);
|
||||
|
|
@ -215,14 +220,14 @@ export class AutoIncludedWorkflowExporter {
|
|||
return target;
|
||||
}
|
||||
|
||||
private ensureFolderChain(options: {
|
||||
private async ensureFolderChain(options: {
|
||||
chain: Folder[];
|
||||
baseDir: string;
|
||||
writer: PackageWriter;
|
||||
folderEntriesById: Map<string, ManifestEntry>;
|
||||
folderEntries: ManifestEntry[];
|
||||
allocators: Map<string, UniqueFilenameAllocator>;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
let parentTarget: string | undefined;
|
||||
let effectiveParentId: string | null = null;
|
||||
|
||||
|
|
@ -241,8 +246,11 @@ export class AutoIncludedWorkflowExporter {
|
|||
if (parentTarget) allocator.reserve('workflows');
|
||||
const target = allocator.allocate(folder.name);
|
||||
const serialized = this.folderSerializer.serialize(folder, effectiveParentId);
|
||||
options.writer.writeDirectory(target);
|
||||
options.writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await options.writer.writeDirectory(target);
|
||||
await options.writer.writeFile(
|
||||
`${target}/folder.json`,
|
||||
JSON.stringify(serialized, null, '\t'),
|
||||
);
|
||||
|
||||
const entry = { id: folder.id, name: folder.name, target };
|
||||
options.folderEntries.push(entry);
|
||||
|
|
@ -263,17 +271,17 @@ export class AutoIncludedWorkflowExporter {
|
|||
return parentTarget;
|
||||
}
|
||||
|
||||
private writeWorkflow(
|
||||
private async writeWorkflow(
|
||||
workflow: WorkflowEntity,
|
||||
baseDir: string,
|
||||
writer: PackageWriter,
|
||||
allocators: Map<string, UniqueFilenameAllocator>,
|
||||
includeTags: boolean,
|
||||
): ManifestEntry {
|
||||
): Promise<ManifestEntry> {
|
||||
const target = allocatorFor(allocators, baseDir, 'workflow').allocate(workflow.name);
|
||||
const serialized = this.workflowSerializer.serialize(workflow, { includeTags });
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
|
||||
return { id: workflow.id, name: workflow.name, target };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,8 +88,11 @@ export class WorkflowExporter {
|
|||
includeTags: request.includeTags,
|
||||
});
|
||||
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
|
||||
await request.writer.writeDirectory(target);
|
||||
await request.writer.writeFile(
|
||||
`${target}/workflow.json`,
|
||||
JSON.stringify(serialized, null, '\t'),
|
||||
);
|
||||
|
||||
entries.push({
|
||||
id: workflow.id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { DirectoryPackageWriter } from '../directory/directory-package-writer';
|
||||
|
||||
describe('DirectoryPackageWriter', () => {
|
||||
let targetDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
targetDir = await mkdtemp(path.join(tmpdir(), 'n8n-dir-writer-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes files and directories directly to disk', async () => {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
|
||||
await writer.writeFile('manifest.json', '{"packageFormatVersion":"1"}');
|
||||
await writer.writeDirectory('projects/alpha/workflows');
|
||||
await writer.writeFile('projects/alpha/workflows/wf/workflow.json', '{"id":"wf"}');
|
||||
|
||||
expect(await readFile(path.join(targetDir, 'manifest.json'), 'utf-8')).toBe(
|
||||
'{"packageFormatVersion":"1"}',
|
||||
);
|
||||
expect(
|
||||
await readFile(path.join(targetDir, 'projects/alpha/workflows/wf/workflow.json'), 'utf-8'),
|
||||
).toBe('{"id":"wf"}');
|
||||
expect((await stat(path.join(targetDir, 'projects/alpha/workflows'))).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('writes files before finalize is called', async () => {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
await writer.writeFile('manifest.json', '{}');
|
||||
|
||||
expect((await stat(path.join(targetDir, 'manifest.json'))).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('creates missing parent directories for a file entry', async () => {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
// No explicit writeDirectory for the parents.
|
||||
await writer.writeFile('a/b/c/deep.json', '{"deep":true}');
|
||||
|
||||
expect(await readFile(path.join(targetDir, 'a/b/c/deep.json'), 'utf-8')).toBe('{"deep":true}');
|
||||
});
|
||||
|
||||
it('normalises leading "./" entry paths', async () => {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
await writer.writeFile('./manifest.json', '{}');
|
||||
|
||||
expect(await readFile(path.join(targetDir, 'manifest.json'), 'utf-8')).toBe('{}');
|
||||
});
|
||||
|
||||
it('refuses to write outside the target directory', async () => {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
await expect(writer.writeFile('../escape.json', '{}')).rejects.toThrow(/outside the target/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import type { Readable } from 'node:stream';
|
||||
|
||||
import type { PackageWriter } from '../../package-writer';
|
||||
|
||||
export class CapturingWriter implements PackageWriter {
|
||||
|
|
@ -15,7 +13,5 @@ export class CapturingWriter implements PackageWriter {
|
|||
this.directories.push(path);
|
||||
}
|
||||
|
||||
finalize(): Readable {
|
||||
throw new Error('CapturingWriter is for unit tests and does not produce a stream');
|
||||
}
|
||||
finalize(): void {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { PackageWriter } from '../package-writer';
|
||||
|
||||
function normaliseEntryPath(entryPath: string): string {
|
||||
return entryPath.startsWith('./') ? entryPath.slice(2) : entryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the n8n-packages layout as loose files into a directory (the "unzipped"
|
||||
* format), rather than a tar archive.
|
||||
*/
|
||||
export class DirectoryPackageWriter implements PackageWriter {
|
||||
constructor(private readonly targetDir: string) {}
|
||||
|
||||
async writeFile(entryPath: string, content: string | Buffer): Promise<void> {
|
||||
const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
}
|
||||
|
||||
async writeDirectory(entryPath: string): Promise<void> {
|
||||
const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath));
|
||||
await mkdir(destination, { recursive: true });
|
||||
}
|
||||
|
||||
async finalize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Entry paths are exporter-generated slugs, but keep writes inside the base dir defensively. */
|
||||
private resolveWithin(baseDir: string, entryPath: string): string {
|
||||
const destination = path.resolve(baseDir, entryPath);
|
||||
const relative = path.relative(baseDir, destination);
|
||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new UnexpectedError(`Refusing to write package entry outside the target: ${entryPath}`);
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import type { Readable } from 'node:stream';
|
||||
|
||||
/** Write surface for exporters: append files and directories. */
|
||||
export interface PackageWriter {
|
||||
writeFile(path: string, content: string | Buffer): void;
|
||||
writeDirectory(path: string): void;
|
||||
finalize(): Readable;
|
||||
writeFile(path: string, content: string | Buffer): void | Promise<void>;
|
||||
writeDirectory(path: string): void | Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import {
|
|||
import { WorkflowDependencyResolver } from './entities/workflow/workflow-dependency-resolver';
|
||||
import { WorkflowRequirementExporter } from './entities/workflow/workflow-requirement.exporter';
|
||||
import { WorkflowExporter } from './entities/workflow/workflow.exporter';
|
||||
import { DirectoryPackageWriter } from './io/directory/directory-package-writer';
|
||||
import type { PackageWriter } from './io/package-writer';
|
||||
import { TarPackageReader } from './io/tar/tar-package-reader';
|
||||
import { TarPackageWriter } from './io/tar/tar-package-writer';
|
||||
import { PackageImportConfig } from './n8n-packages.config';
|
||||
|
|
@ -41,6 +43,7 @@ import {
|
|||
type ExportPackageEventCounts,
|
||||
type ExportPackageRequest,
|
||||
type ExportPackageResult,
|
||||
type ExportPackageSummary,
|
||||
type ImportPackageRequest,
|
||||
type ImportResult,
|
||||
} from './n8n-packages.types';
|
||||
|
|
@ -52,6 +55,14 @@ import {
|
|||
} from './spec/manifest.schema';
|
||||
import type { PackageRequirements } from './spec/requirements.schema';
|
||||
|
||||
interface WrittenExport {
|
||||
counts: ExportPackageEventCounts;
|
||||
workflowIds: string[];
|
||||
folderIds: string[];
|
||||
projectIds: string[];
|
||||
credentialExportPolicy: CredentialExportPolicy;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class N8nPackagesService {
|
||||
constructor(
|
||||
|
|
@ -76,11 +87,46 @@ export class N8nPackagesService {
|
|||
) {}
|
||||
|
||||
async exportPackage(request: ExportPackageRequest): Promise<ExportPackageResult> {
|
||||
const writer = new TarPackageWriter();
|
||||
const result = await this.writeExport(writer, request);
|
||||
const stream = writer.finalize();
|
||||
|
||||
// This event represents a user-facing archive export, not an internal directory write.
|
||||
this.eventService.emit('n8n-package-exported', {
|
||||
user: request.user,
|
||||
...(result.workflowIds.length ? { workflowIds: result.workflowIds } : {}),
|
||||
...(result.folderIds.length ? { folderIds: result.folderIds } : {}),
|
||||
...(result.projectIds.length ? { projectIds: result.projectIds } : {}),
|
||||
counts: result.counts,
|
||||
credentialExportPolicy: result.credentialExportPolicy,
|
||||
});
|
||||
|
||||
return { stream, counts: result.counts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the same n8n-packages layout as {@link exportPackage}, but as loose
|
||||
* files on disk (the unzipped format) under `target.targetDir` instead of a tar
|
||||
* stream. Reuses the full export orchestration; only the writer differs.
|
||||
*/
|
||||
async exportPackageToDirectory(
|
||||
request: ExportPackageRequest,
|
||||
target: { targetDir: string },
|
||||
): Promise<ExportPackageSummary> {
|
||||
const writer = new DirectoryPackageWriter(target.targetDir);
|
||||
const result = await this.writeExport(writer, request);
|
||||
await writer.finalize();
|
||||
return { counts: result.counts };
|
||||
}
|
||||
|
||||
private async writeExport(
|
||||
writer: PackageWriter,
|
||||
request: ExportPackageRequest,
|
||||
): Promise<WrittenExport> {
|
||||
const { missingWorkflowDependencyPolicy } = request;
|
||||
const isReferenceOnly =
|
||||
missingWorkflowDependencyPolicy === MissingWorkflowDependencyPolicy.ReferenceOnly;
|
||||
|
||||
const writer = new TarPackageWriter();
|
||||
const workflowIds = request.workflowIds ?? [];
|
||||
const folderIds = request.folderIds ?? [];
|
||||
const projectIds = request.projectIds ?? [];
|
||||
|
|
@ -161,7 +207,7 @@ export class N8nPackagesService {
|
|||
workflowVersionPolicy,
|
||||
});
|
||||
|
||||
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
|
||||
autoIncludedExportResult = await this.autoIncludedWorkflowExporter.export({
|
||||
writer,
|
||||
workflows: autoIncludedWorkflowResolution.autoIncludedWorkflows,
|
||||
existingWorkflowEntries: allWorkflowsBeforeAutoInclude,
|
||||
|
|
@ -247,7 +293,7 @@ export class N8nPackagesService {
|
|||
projectTargetsById,
|
||||
});
|
||||
|
||||
const tagExportResult = this.tagExporter.export({
|
||||
const tagExportResult = await this.tagExporter.export({
|
||||
usages: requirements.tags,
|
||||
writer,
|
||||
});
|
||||
|
|
@ -282,9 +328,7 @@ export class N8nPackagesService {
|
|||
...(allProjects.length > 0 ? { projects: allProjects } : {}),
|
||||
});
|
||||
|
||||
writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));
|
||||
|
||||
const stream = writer.finalize();
|
||||
await writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));
|
||||
|
||||
const counts: ExportPackageEventCounts = {
|
||||
workflows: allWorkflowsInPackage.length,
|
||||
|
|
@ -295,18 +339,13 @@ export class N8nPackagesService {
|
|||
tags: tagExportResult.entries.length,
|
||||
};
|
||||
|
||||
this.eventService.emit('n8n-package-exported', {
|
||||
user: request.user,
|
||||
...(allWorkflowsInPackage.length
|
||||
? { workflowIds: allWorkflowsInPackage.map(({ id }) => id) }
|
||||
: {}),
|
||||
...(allFolders.length ? { folderIds: allFolders.map(({ id }) => id) } : {}),
|
||||
...(allProjects.length ? { projectIds: allProjects.map(({ id }) => id) } : {}),
|
||||
return {
|
||||
counts,
|
||||
workflowIds: allWorkflowsInPackage.map(({ id }) => id),
|
||||
folderIds: allFolders.map(({ id }) => id),
|
||||
projectIds: allProjects.map(({ id }) => id),
|
||||
credentialExportPolicy,
|
||||
});
|
||||
|
||||
return { stream, counts };
|
||||
};
|
||||
}
|
||||
|
||||
async importPackage(request: ImportPackageRequest): Promise<ImportResult> {
|
||||
|
|
|
|||
|
|
@ -383,15 +383,24 @@ export type ExportPackageEventCounts = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Result of an export: the archive stream plus the true per-entity counts of
|
||||
* what actually ended up in the package (after folder bundling and
|
||||
* auto-inclusion). Consumers surface these instead of the requested id counts.
|
||||
* Summary of what an export produced: the true per-entity counts of what
|
||||
* actually ended up in the package (after folder bundling and auto-inclusion).
|
||||
* Always available regardless of the sink — consumers surface these instead of
|
||||
* the requested id counts.
|
||||
*/
|
||||
export interface ExportPackageResult {
|
||||
stream: Readable;
|
||||
export interface ExportPackageSummary {
|
||||
counts: ExportPackageEventCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an export where the package itself is returned to the caller as an
|
||||
* archive stream, on top of the summary. Contrast with a directory export, which
|
||||
* writes to disk in place and only returns the {@link ExportPackageSummary}.
|
||||
*/
|
||||
export interface ExportPackageResult extends ExportPackageSummary {
|
||||
stream: Readable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome for one package workflow, folding in what the publish phase decided for it. Import
|
||||
* writes and publishes in two separate phases, but a consumer cannot act on that distinction, so
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
GitConnectionProjectListPublicDto,
|
||||
GitConnectionProjectPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
GitConnectionPushResultDto,
|
||||
ListGitConnectionsQueryDto,
|
||||
MAX_ITEMS_PER_PAGE,
|
||||
UpdateGitConnectionDto,
|
||||
|
|
@ -196,6 +197,27 @@ export class GitConnectionsPublicController {
|
|||
await (await this.gitConnectionsService()).delete(id);
|
||||
}
|
||||
|
||||
@Post('/:id/push')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:push')
|
||||
@GlobalScope('gitConnection:push')
|
||||
@ApiSummary('Push all projects linked to a Git connection')
|
||||
@ApiDescription(
|
||||
'Work in progress. Exports all linked projects to the local repository working copy. It does not commit or push changes to the selected branch yet.',
|
||||
)
|
||||
@ApiTags(tags)
|
||||
@ApiResponse(200, GitConnectionPushResultDto)
|
||||
@ApiErrorResponse(400)
|
||||
@ApiErrorResponse(404)
|
||||
@ApiErrorResponse(503)
|
||||
async pushGitConnectionProjects(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
): Promise<GitConnectionPushResultDto> {
|
||||
return await (await this.gitConnectionsService()).push(id, req.user);
|
||||
}
|
||||
|
||||
@Get('/:id/projects')
|
||||
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
|
||||
@ApiKeyScope('gitConnection:read')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
operationId: pushGitConnectionProjects
|
||||
tags:
|
||||
- GitConnections
|
||||
summary: Push all projects linked to a Git connection
|
||||
description: Work in progress. Exports all linked projects to the local repository working copy. It does not commit or push changes to the selected branch yet.
|
||||
x-required-scope: gitConnection:push
|
||||
x-eov-operation-id: unreachable
|
||||
x-eov-operation-handler: v1/handlers/decorator-routed.handler
|
||||
x-decorator-routed: true
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
required: true
|
||||
name: id
|
||||
in: path
|
||||
responses:
|
||||
'200':
|
||||
description: Operation successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
connectionId:
|
||||
type: string
|
||||
counts:
|
||||
type: object
|
||||
properties:
|
||||
workflows:
|
||||
type: integer
|
||||
minimum: 0
|
||||
folders:
|
||||
type: integer
|
||||
minimum: 0
|
||||
credentials:
|
||||
type: integer
|
||||
minimum: 0
|
||||
dataTables:
|
||||
type: integer
|
||||
minimum: 0
|
||||
variables:
|
||||
type: integer
|
||||
minimum: 0
|
||||
tags:
|
||||
type: integer
|
||||
minimum: 0
|
||||
required:
|
||||
- workflows
|
||||
- folders
|
||||
- credentials
|
||||
- dataTables
|
||||
- variables
|
||||
- tags
|
||||
required:
|
||||
- connectionId
|
||||
- counts
|
||||
'400':
|
||||
$ref: ../../../../shared/spec/responses/badRequest.yml
|
||||
'401':
|
||||
$ref: ../../../../shared/spec/responses/unauthorized.yml
|
||||
'403':
|
||||
$ref: ../../../../shared/spec/responses/forbidden.yml
|
||||
'404':
|
||||
$ref: ../../../../shared/spec/responses/notFound.yml
|
||||
'503':
|
||||
$ref: ../../../../shared/spec/responses/serviceUnavailable.yml
|
||||
|
|
@ -36,6 +36,7 @@ export const ERROR_RESPONSE_REFS = {
|
|||
403: { $ref: '../../../../shared/spec/responses/forbidden.yml' },
|
||||
404: { $ref: '../../../../shared/spec/responses/notFound.yml' },
|
||||
409: { $ref: '../../../../shared/spec/responses/conflict.yml' },
|
||||
503: { $ref: '../../../../shared/spec/responses/serviceUnavailable.yml' },
|
||||
} as const satisfies Record<number, { $ref: string }>;
|
||||
|
||||
type DocumentedErrorStatus = keyof typeof ERROR_RESPONSE_REFS;
|
||||
|
|
@ -55,6 +56,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record<DocumentedErrorStatus, string>
|
|||
403: 'Forbidden',
|
||||
404: 'The specified resource was not found.',
|
||||
409: 'Conflict',
|
||||
503: 'The requested service is temporarily unavailable.',
|
||||
};
|
||||
|
||||
/** A `ResponseDtoClass` narrowed to the two fields the generator actually reads off it. */
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ paths:
|
|||
/git-connections/{id}/disconnect:
|
||||
post:
|
||||
$ref: ./handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml
|
||||
/git-connections/{id}/push:
|
||||
post:
|
||||
$ref: ./handlers/git-connections/spec/paths/pushGitConnectionProjects.generated.yml
|
||||
/git-connections/{id}/projects:
|
||||
get:
|
||||
$ref: ./handlers/git-connections/spec/paths/getGitConnectionProjects.generated.yml
|
||||
|
|
|
|||
|
|
@ -10,3 +10,5 @@ Forbidden:
|
|||
$ref: './forbidden.yml'
|
||||
PaymentRequired:
|
||||
$ref: './paymentRequired.yml'
|
||||
ServiceUnavailable:
|
||||
$ref: './serviceUnavailable.yml'
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
description: The requested service is temporarily unavailable.
|
||||
|
|
@ -238,6 +238,9 @@
|
|||
"POST /source-control/pull": {
|
||||
"status": "gap"
|
||||
},
|
||||
"POST /git-connections/{id}/push": {
|
||||
"status": "gap"
|
||||
},
|
||||
"GET /settings/security-policy": {
|
||||
"status": "gap"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user