mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Add pinned Apply and Apply Continue to the promotion CLI commands (#39597)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88c3962423
commit
6c57533958
|
|
@ -61,7 +61,7 @@ n8n-cli --url=https://my-n8n.app.n8n.cloud --api-key=n8n_api_xxxxx workflow list
|
|||
| `user` | `list`, `get` |
|
||||
| `config` | `set-url`, `set-api-key`, `show` |
|
||||
| `promotion-provider` | `list`, `get`, `create`, `update`, `delete` |
|
||||
| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `promote`, `apply`, `list-projects`, `add-project`, `remove-project` |
|
||||
| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `list-changes`, `promote`, `promote-selection`, `apply`, `apply-continue`, `list-projects`, `add-project`, `remove-project` |
|
||||
| `source-control` | `pull` |
|
||||
| `package` | `export`, `import` _(beta)_ |
|
||||
| `skill` | `install` |
|
||||
|
|
|
|||
|
|
@ -235,11 +235,18 @@ n8n-cli promotion-connection list-changes proj-abc promote
|
|||
n8n-cli promotion-connection list-changes proj-abc apply
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--search` | List only the workflows whose name matches this text. |
|
||||
| `--sort` | Sort by `name`, `updatedAt`, or `status`. The default is `name`. |
|
||||
| `--order` | Sort order: `asc` or `desc`. The default is `asc`. |
|
||||
|
||||
For `promote` the rows are what a promotion would send to the branch, and the
|
||||
API key needs `gitConnection:push`. For `apply` the rows are what applying the
|
||||
branch would change on this instance, and the key needs `gitConnection:pull`.
|
||||
`commitSha` is the commit the rows were read from. Clone the direction first.
|
||||
Use the `id` of each row to build a selective promote.
|
||||
`commitSha` is the commit the rows were read from. Table output shows the rows
|
||||
only. Use `--json` to get the rows and `commitSha` together. Clone the direction
|
||||
first. Use the `id` of each row to build a selective promote.
|
||||
|
||||
## `promotion-connection promote-selection`
|
||||
|
||||
|
|
@ -269,10 +276,68 @@ package. This overwrites the instance to match the branch.
|
|||
|
||||
```bash
|
||||
n8n-cli promotion-connection apply conn-1
|
||||
n8n-cli promotion-connection apply conn-1 \
|
||||
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<full sha>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--expected-config-id` | ID of the apply configuration that you reviewed (`configs.apply.id`). |
|
||||
| `--expected-branch` | Branch that you reviewed. |
|
||||
| `--expected-commit-sha` | Full commit SHA that you reviewed (40 or 64 lowercase hex characters). |
|
||||
|
||||
Without the `--expected-*` flags, the command applies the branch tip. With them,
|
||||
the command applies only the source that you reviewed. Pass all three flags or
|
||||
none.
|
||||
|
||||
| Result | Exit code | What happened |
|
||||
|--------|-----------|---------------|
|
||||
| `applied` | `0` | The package was imported. JSON output includes `counts` and `warnings`. |
|
||||
| `source-changed` | `3` | The configuration, branch, or commit is not the one you reviewed. Nothing was imported. Review the changes again. |
|
||||
| `blocked` | `4` | Preflight found missing bindings, access requirements, or conflicts. Nothing was imported. The output shows the `apply-continue` command to run after you resolve them. |
|
||||
|
||||
With `--json`, the output is the full result for each status. For `blocked`, the
|
||||
`preflight` object lists each missing project, missing binding, access
|
||||
requirement, and conflict. Existing variable values on this instance are kept.
|
||||
|
||||
Clone the `apply` direction first. This command works on the `instance`
|
||||
connection only.
|
||||
connection only. The API key needs `gitConnection:pull`.
|
||||
|
||||
## `promotion-connection apply-continue`
|
||||
|
||||
Continue an Apply that was `blocked`, after you resolve the missing bindings, access requirements, or conflicts.
|
||||
|
||||
```bash
|
||||
n8n-cli promotion-connection apply-continue conn-1 \
|
||||
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<full sha>
|
||||
```
|
||||
|
||||
The three `--expected-*` flags are required. Use the `configId`,
|
||||
`git.branchName`, and `git.commitSha` that the blocked Apply reported. The
|
||||
command runs the preflight again. The results and exit codes are the same as
|
||||
for `apply`: `blocked` again means that something still blocks, and
|
||||
`source-changed` means that you must review again. The server keeps no session,
|
||||
so run the command again when it exits `4`.
|
||||
|
||||
## Apply a reviewed commit
|
||||
|
||||
```bash
|
||||
# 1. Review the changes. Take the commit SHA from the same response as the rows:
|
||||
# a second request can read a newer commit.
|
||||
n8n-cli promotion-connection list-changes proj-abc apply --json > reviewed-changes.json
|
||||
jq '{commitSha, changes}' reviewed-changes.json
|
||||
|
||||
# 2. Read the apply configuration ID and branch of the connection.
|
||||
n8n-cli promotion-connection get conn-1 --jq '.configs.apply.id'
|
||||
n8n-cli promotion-connection get conn-1 --jq '.configs.apply.settings.branchName'
|
||||
|
||||
# 3. Apply that commit only.
|
||||
n8n-cli promotion-connection apply conn-1 \
|
||||
--expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha=<sha from step 1>
|
||||
|
||||
# 4. On exit code 4, resolve the missing bindings, access requirements, or conflicts.
|
||||
# Then run the apply-continue command that step 3 printed.
|
||||
```
|
||||
|
||||
## `promotion-connection list-projects` / `add-project` / `remove-project`
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ n8n-cli execution list --workflow=1234 --status=error --limit=5 --format=json
|
|||
| `0` | Success |
|
||||
| `1` | General error |
|
||||
| `2` | Authentication failure |
|
||||
| `3` | Promotion Apply: the source changed since the review. Nothing was imported |
|
||||
| `4` | Promotion Apply: preflight found missing bindings, access requirements, or conflicts. Nothing was imported |
|
||||
|
||||
AI agents can branch on `$?` for error handling.
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,26 @@ n8n-cli promotion-connection promote <id> -m "Promote team projects"
|
|||
n8n-cli promotion-connection apply <id>
|
||||
```
|
||||
|
||||
Apply a reviewed commit only:
|
||||
|
||||
```bash
|
||||
# Review the changes. Take the commit SHA from the same response as the rows:
|
||||
# a second request can read a newer commit.
|
||||
n8n-cli promotion-connection list-changes <projectId> apply --sort=updatedAt --order=desc --json > reviewed-changes.json
|
||||
jq '{commitSha, changes}' reviewed-changes.json
|
||||
# The config ID and branch come from the connection.
|
||||
n8n-cli promotion-connection get <id> --jq '.configs.apply.id'
|
||||
n8n-cli promotion-connection get <id> --jq '.configs.apply.settings.branchName'
|
||||
|
||||
n8n-cli promotion-connection apply <id> \
|
||||
--expected-config-id=<configId> --expected-branch=<branch> --expected-commit-sha=<full sha>
|
||||
# Exit 3: the source changed since the review. Review again.
|
||||
# Exit 4: preflight found missing bindings, access requirements, or conflicts. Resolve them,
|
||||
# then run the apply-continue command that apply printed:
|
||||
n8n-cli promotion-connection apply-continue <id> \
|
||||
--expected-config-id=<configId> --expected-branch=<branch> --expected-commit-sha=<full sha>
|
||||
```
|
||||
|
||||
Connection JSON for step 2:
|
||||
|
||||
```json
|
||||
|
|
@ -274,6 +294,12 @@ Key points:
|
|||
- `createBranchOnPromotion` is always required in a promote configuration.
|
||||
- `promote` and `apply` work on the `instance` connection only, and need their
|
||||
direction cloned first. Cloning one direction does not make the other ready.
|
||||
- `apply` and `apply-continue` import nothing on exit code `3` (`source-changed`)
|
||||
or `4` (`blocked`). Check `$?`, or the `status` field in JSON output, before you
|
||||
read `counts`. With `--json`, a `blocked` result lists what is missing under
|
||||
`preflight`.
|
||||
- The `--expected-*` flags go together: pass all three or none. The commit SHA
|
||||
must be the full SHA.
|
||||
- API key scopes for this group are named `gitConnection:*`. `promote` also needs
|
||||
`variable:list` when the workflows reference variables.
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,69 @@ describe('N8nClient packages', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const expectedSource = {
|
||||
configId: 'cfg-2',
|
||||
branchName: 'release',
|
||||
commitSha: 'a'.repeat(40),
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: 'posts apply with no body when no expected source is given',
|
||||
send: async () => await client.applyPackage('conn-1'),
|
||||
path: 'apply',
|
||||
body: undefined,
|
||||
},
|
||||
{
|
||||
title: 'posts apply with the expected source in the body',
|
||||
send: async () => await client.applyPackage('conn-1', expectedSource),
|
||||
path: 'apply',
|
||||
body: { expectedSource },
|
||||
},
|
||||
{
|
||||
title: 'posts continue to apply/continue with the expected source in the body',
|
||||
send: async () => await client.continueApplyPackage('conn-1', expectedSource),
|
||||
path: 'apply/continue',
|
||||
body: { expectedSource },
|
||||
},
|
||||
])('$title', async ({ send, path, body }) => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { status: 'applied' }));
|
||||
|
||||
await send();
|
||||
|
||||
expect(requestLines(fetchMock)).toEqual([
|
||||
`POST https://n8n.example.com/api/v1/promotions/connections/conn-1/${path}`,
|
||||
]);
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(typeof init.body === 'string' ? JSON.parse(init.body) : init.body).toEqual(body);
|
||||
});
|
||||
|
||||
describe('list changes', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(200, { commitSha: null, changes: [] }));
|
||||
});
|
||||
|
||||
it('sends no query parameters when no options are given', async () => {
|
||||
await client.listProjectPromotionChanges('proj-1', 'apply');
|
||||
|
||||
expect(requestLines(fetchMock)).toEqual([
|
||||
'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/apply',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends search, sort, and order as query parameters', async () => {
|
||||
await client.listProjectPromotionChanges('proj-1', 'promote', {
|
||||
search: 'checkout',
|
||||
sort: 'updatedAt',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
expect(requestLines(fetchMock)).toEqual([
|
||||
'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/promote?search=checkout&sort=updatedAt&order=desc',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('clones and disconnects one direction of a connection', async () => {
|
||||
const checkout = {
|
||||
connectionId: 'conn-1',
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const CASES: WiringCase[] = [
|
|||
commandClass: PromotionConnectionListChanges,
|
||||
args: { projectId: 'proj-1', direction: 'promote' },
|
||||
method: 'listProjectPromotionChanges',
|
||||
callArgs: ['proj-1', 'promote'],
|
||||
callArgs: ['proj-1', 'promote', {}],
|
||||
},
|
||||
{
|
||||
title: 'promotion-connection add-project',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ interface ListChangesFlags {
|
|||
format?: string;
|
||||
json?: boolean;
|
||||
jq?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
}
|
||||
|
||||
/** The command methods we stub to isolate behaviour from oclif/networking. */
|
||||
|
|
@ -51,7 +54,7 @@ function stubCommand(flags: ListChangesFlags) {
|
|||
} as unknown as N8nClient);
|
||||
const output = vi.spyOn(internals, 'output').mockImplementation(() => {});
|
||||
|
||||
return { command, output };
|
||||
return { command, output, listProjectPromotionChanges };
|
||||
}
|
||||
|
||||
describe('promotion-connection list-changes command', () => {
|
||||
|
|
@ -59,6 +62,22 @@ describe('promotion-connection list-changes command', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('passes the search, sort, and order flags to the change preview', async () => {
|
||||
const { command, listProjectPromotionChanges } = stubCommand({
|
||||
search: 'checkout',
|
||||
sort: 'updatedAt',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(listProjectPromotionChanges).toHaveBeenCalledWith('proj-1', 'promote', {
|
||||
search: 'checkout',
|
||||
sort: 'updatedAt',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps the changes array for table output so rows render individually', async () => {
|
||||
const { command, output } = stubCommand({ format: 'table' });
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||
|
||||
import type { N8nClient } from '../client';
|
||||
import PromotionConnectionApply from '../commands/promotion-connection/apply';
|
||||
import PromotionConnectionApplyContinue from '../commands/promotion-connection/apply-continue';
|
||||
import {
|
||||
parseCommitSha,
|
||||
parseNonEmpty,
|
||||
toExpectedSource,
|
||||
} from '../commands/promotion-connection/expected-source';
|
||||
import PromotionConnectionPromote from '../commands/promotion-connection/promote';
|
||||
import PromotionConnectionPromoteSelection from '../commands/promotion-connection/promote-selection';
|
||||
|
||||
|
|
@ -13,6 +19,12 @@ interface OperationInternals {
|
|||
succeed: (message: string, ...rest: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface ApplyInternals extends OperationInternals {
|
||||
output: (data: unknown, flags: unknown) => void;
|
||||
logToStderr: (message: string) => void;
|
||||
exit: (code: number) => never;
|
||||
}
|
||||
|
||||
const PROMOTE_RESULT = {
|
||||
connectionId: 'conn-1',
|
||||
configId: 'cfg-1',
|
||||
|
|
@ -20,15 +32,38 @@ const PROMOTE_RESULT = {
|
|||
git: { commitSha: 'abc1234', branchName: 'main' },
|
||||
};
|
||||
|
||||
const APPLY_RESULT = {
|
||||
const SHA = 'd'.repeat(40);
|
||||
const IDENTITY = {
|
||||
connectionId: 'conn-1',
|
||||
configId: 'cfg-2',
|
||||
counts: {},
|
||||
git: { commitSha: 'def5678', branchName: 'release' },
|
||||
git: { commitSha: SHA, branchName: 'release' },
|
||||
};
|
||||
const APPLY_RESULT = { ...IDENTITY, status: 'applied', counts: {}, warnings: [] };
|
||||
const SOURCE_CHANGED_RESULT = { ...IDENTITY, status: 'source-changed' };
|
||||
const BLOCKED_RESULT = {
|
||||
...IDENTITY,
|
||||
status: 'blocked',
|
||||
preflight: {
|
||||
missingProjects: [{ id: 'p-1', name: 'Sales' }],
|
||||
missingBindings: [
|
||||
{ kind: 'credential', sourceId: 'c-1' },
|
||||
{ kind: 'credential', sourceId: 'c-2' },
|
||||
{ kind: 'variable', name: 'API_URL' },
|
||||
],
|
||||
accessRequirements: [],
|
||||
conflicts: [{ kind: 'variable', code: 'missing-definition' }],
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
const PINNED_FLAGS = {
|
||||
expectedConfigId: 'cfg-2',
|
||||
expectedBranch: 'release',
|
||||
expectedCommitSha: SHA,
|
||||
};
|
||||
const EXPECTED_SOURCE = { configId: 'cfg-2', branchName: 'release', commitSha: SHA };
|
||||
|
||||
function stubCommand(
|
||||
command: PromotionConnectionPromote | PromotionConnectionApply,
|
||||
command: PromotionConnectionPromote | PromotionConnectionApply | PromotionConnectionApplyContinue,
|
||||
flags: Record<string, unknown>,
|
||||
client: Partial<N8nClient>,
|
||||
) {
|
||||
|
|
@ -42,6 +77,19 @@ function stubCommand(
|
|||
return succeed;
|
||||
}
|
||||
|
||||
function stubApplyCommand(
|
||||
command: PromotionConnectionApply | PromotionConnectionApplyContinue,
|
||||
flags: Record<string, unknown>,
|
||||
client: Partial<N8nClient>,
|
||||
) {
|
||||
const succeed = stubCommand(command, flags, client);
|
||||
const internals = command as unknown as ApplyInternals;
|
||||
const output = vi.spyOn(internals, 'output').mockImplementation(() => {});
|
||||
const logToStderr = vi.spyOn(internals, 'logToStderr').mockImplementation(() => {});
|
||||
const exit = vi.spyOn(internals, 'exit').mockImplementation(() => undefined as never);
|
||||
return { succeed, output, logToStderr, exit };
|
||||
}
|
||||
|
||||
describe('promotion-connection promote command', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -76,22 +124,177 @@ describe('promotion-connection apply command', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('names the branch and commit it applied', async () => {
|
||||
function runApply(result: unknown, flags: Record<string, unknown> = { format: 'table' }) {
|
||||
const command = new PromotionConnectionApply([], {} as Config);
|
||||
const applyPackage = vi.fn().mockResolvedValue(APPLY_RESULT);
|
||||
const succeed = stubCommand(command, {}, {
|
||||
const applyPackage = vi.fn().mockResolvedValue(result);
|
||||
const stubs = stubApplyCommand(command, flags, {
|
||||
applyPackage,
|
||||
} as unknown as Partial<N8nClient>);
|
||||
return { run: async () => await command.run(), applyPackage, ...stubs };
|
||||
}
|
||||
|
||||
it('sends the reviewed source built from the three expected flags', async () => {
|
||||
const { run, applyPackage } = runApply(APPLY_RESULT, { format: 'table', ...PINNED_FLAGS });
|
||||
|
||||
await run();
|
||||
|
||||
expect(applyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE);
|
||||
});
|
||||
|
||||
it('names the branch and commit it applied, and exits through succeed', async () => {
|
||||
const { run, succeed, exit } = runApply(APPLY_RESULT);
|
||||
|
||||
await run();
|
||||
|
||||
expect(succeed).toHaveBeenCalledWith(
|
||||
`Applied release at commit ${SHA} to the instance.`,
|
||||
expect.anything(),
|
||||
APPLY_RESULT,
|
||||
);
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the warnings of a successful Apply in the Applied message', async () => {
|
||||
const result = { ...APPLY_RESULT, warnings: [{}, {}] };
|
||||
const { run, succeed } = runApply(result);
|
||||
|
||||
await run();
|
||||
|
||||
expect(succeed).toHaveBeenCalledWith(
|
||||
`Applied release at commit ${SHA} to the instance with 2 warning(s).`,
|
||||
expect.anything(),
|
||||
result,
|
||||
);
|
||||
});
|
||||
|
||||
it('exits 3 without reporting success when the source changed since the review', async () => {
|
||||
const { run, succeed, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT);
|
||||
|
||||
await run();
|
||||
|
||||
expect(succeed).not.toHaveBeenCalled();
|
||||
expect(logToStderr).toHaveBeenCalledWith(expect.stringContaining('Nothing was imported.'));
|
||||
expect(logToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`config cfg-2, branch release, commit ${SHA}`),
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('exits 4 with binding counts and a ready Continue command when bindings are missing', async () => {
|
||||
const { run, succeed, logToStderr, exit } = runApply(BLOCKED_RESULT);
|
||||
|
||||
await run();
|
||||
|
||||
expect(succeed).not.toHaveBeenCalled();
|
||||
const message = logToStderr.mock.calls[0][0];
|
||||
expect(message).toContain('Missing projects: 1');
|
||||
expect(message).toContain('Missing credentials: 2');
|
||||
expect(message).toContain('Missing variables: 1');
|
||||
expect(message).toContain('Access requirements: 0');
|
||||
expect(message).toContain('Conflicts: 1');
|
||||
expect(message).toContain(
|
||||
`n8n-cli promotion-connection apply-continue conn-1 --expected-config-id=cfg-2 --expected-branch=release --expected-commit-sha=${SHA}`,
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('quotes source values in the Continue command so it is safe to paste', async () => {
|
||||
const { run, logToStderr } = runApply({
|
||||
...BLOCKED_RESULT,
|
||||
git: { commitSha: SHA, branchName: "release;$(id)'s" },
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(logToStderr.mock.calls[0][0]).toContain(
|
||||
`--expected-branch='release;$(id)'\\''s' --expected-commit-sha=${SHA}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ result: APPLY_RESULT, code: 0 },
|
||||
{ result: BLOCKED_RESULT, code: 4 },
|
||||
])(
|
||||
'sends the $result.status result through the jq-aware output in JSON mode',
|
||||
async ({ result, code }) => {
|
||||
const { run, succeed, output, logToStderr, exit } = runApply(result, { jq: '.status' });
|
||||
|
||||
await run();
|
||||
|
||||
expect(output).toHaveBeenCalledWith(result, expect.objectContaining({ jq: '.status' }));
|
||||
expect(succeed).not.toHaveBeenCalled();
|
||||
expect(logToStderr).not.toHaveBeenCalled();
|
||||
expect(exit).toHaveBeenCalledWith(code);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the exit code when quiet', async () => {
|
||||
const { run, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT, {
|
||||
format: 'table',
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(logToStderr).not.toHaveBeenCalled();
|
||||
expect(exit).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promotion-connection apply-continue command', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('continues with the reviewed source and reports the result like Apply', async () => {
|
||||
const command = new PromotionConnectionApplyContinue([], {} as Config);
|
||||
const continueApplyPackage = vi.fn().mockResolvedValue(SOURCE_CHANGED_RESULT);
|
||||
const { exit } = stubApplyCommand(command, { format: 'table', ...PINNED_FLAGS }, {
|
||||
continueApplyPackage,
|
||||
} as unknown as Partial<N8nClient>);
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(applyPackage).toHaveBeenCalledWith('conn-1');
|
||||
expect(succeed).toHaveBeenCalledWith(
|
||||
'Applied release at commit def5678 to the instance.',
|
||||
expect.anything(),
|
||||
APPLY_RESULT,
|
||||
expect(continueApplyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE);
|
||||
expect(exit).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expected config ID and branch flags', () => {
|
||||
it('reject a value that is empty after trimming', async () => {
|
||||
await expect(parseNonEmpty('branch name')(' ')).rejects.toThrow(
|
||||
'Expected a non-empty branch name',
|
||||
);
|
||||
});
|
||||
|
||||
it('keep a non-empty value unchanged', async () => {
|
||||
await expect(parseNonEmpty('branch name')('release/1.0')).resolves.toBe('release/1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expected source from flags', () => {
|
||||
it('pins nothing when no flag is given', () => {
|
||||
expect(toExpectedSource({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an empty value so the request fails instead of applying the branch tip', () => {
|
||||
expect(
|
||||
toExpectedSource({ expectedConfigId: 'cfg-2', expectedBranch: '', expectedCommitSha: SHA }),
|
||||
).toEqual({ configId: 'cfg-2', branchName: '', commitSha: SHA });
|
||||
});
|
||||
});
|
||||
|
||||
describe('expected commit SHA flag', () => {
|
||||
it.each(['a'.repeat(40), '0123456789abcdef'.repeat(4)])('accepts %s', async (sha) => {
|
||||
await expect(parseCommitSha(sha)).resolves.toBe(sha);
|
||||
});
|
||||
|
||||
it.each(['abc1234', 'A'.repeat(40), 'g'.repeat(40), 'a'.repeat(41)])(
|
||||
'rejects %s',
|
||||
async (sha) => {
|
||||
await expect(parseCommitSha(sha)).rejects.toThrow('full lowercase commit SHA');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('promotion-connection promote-selection command', () => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { resolveConnection } from './config';
|
|||
import { formatOutput, applyJqFilter, type OutputFormat, type OutputOptions } from './output';
|
||||
|
||||
/** Exit codes following the RFC spec. */
|
||||
const EXIT_SUCCESS = 0;
|
||||
export const EXIT_SUCCESS = 0;
|
||||
const EXIT_ERROR = 1;
|
||||
const EXIT_AUTH = 2;
|
||||
export const EXIT_SOURCE_CHANGED = 3;
|
||||
export const EXIT_BLOCKED = 4;
|
||||
|
||||
export abstract class BaseCommand extends Command {
|
||||
static override baseFlags = {
|
||||
|
|
|
|||
|
|
@ -108,14 +108,44 @@ export type PromotePackageResult = {
|
|||
git: PromotionGitResult;
|
||||
};
|
||||
|
||||
/** Outcome of applying a package to the instance. */
|
||||
export type ApplyPackageResult = {
|
||||
/** The reviewed source that Apply must still match. Any mismatch reports `source-changed`. */
|
||||
export interface PromotionExpectedSource {
|
||||
configId: string;
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
/** References in a package that must be set up on this instance before Apply can import it. */
|
||||
export interface PromotionBindingPreflight {
|
||||
missingProjects: Array<Record<string, unknown>>;
|
||||
missingBindings: Array<Record<string, unknown>>;
|
||||
accessRequirements: Array<Record<string, unknown>>;
|
||||
conflicts: Array<Record<string, unknown>>;
|
||||
warnings: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
type ApplyPackageIdentity = {
|
||||
connectionId: string;
|
||||
configId: string;
|
||||
counts: ImportPackageCounts;
|
||||
git: PromotionGitResult;
|
||||
};
|
||||
|
||||
/** Outcome of applying a package to the instance. Only `applied` imported anything. */
|
||||
export type ApplyPackageResult =
|
||||
| (ApplyPackageIdentity & {
|
||||
status: 'applied';
|
||||
counts: ImportPackageCounts;
|
||||
warnings: Array<Record<string, unknown>>;
|
||||
})
|
||||
| (ApplyPackageIdentity & { status: 'blocked'; preflight: PromotionBindingPreflight })
|
||||
| (ApplyPackageIdentity & { status: 'source-changed' });
|
||||
|
||||
export interface PromotionChangesQuery {
|
||||
search?: string;
|
||||
sort?: 'name' | 'updatedAt' | 'status';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** One workflow that differs between a project and its configured branch. */
|
||||
export interface PromotableResourceSummary {
|
||||
id: string;
|
||||
|
|
@ -391,13 +421,27 @@ export class N8nClient {
|
|||
return await this.post<PromotePackageResult>(`/promotions/connections/${id}/promote`, body);
|
||||
}
|
||||
|
||||
async applyPackage(id: string) {
|
||||
return await this.post<ApplyPackageResult>(`/promotions/connections/${id}/apply`);
|
||||
async applyPackage(id: string, expectedSource?: PromotionExpectedSource) {
|
||||
return await this.post<ApplyPackageResult>(
|
||||
`/promotions/connections/${id}/apply`,
|
||||
expectedSource ? { expectedSource } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async listProjectPromotionChanges(projectId: string, direction: PromotionDirection) {
|
||||
async continueApplyPackage(id: string, expectedSource: PromotionExpectedSource) {
|
||||
return await this.post<ApplyPackageResult>(`/promotions/connections/${id}/apply/continue`, {
|
||||
expectedSource,
|
||||
});
|
||||
}
|
||||
|
||||
async listProjectPromotionChanges(
|
||||
projectId: string,
|
||||
direction: PromotionDirection,
|
||||
query: PromotionChangesQuery = {},
|
||||
) {
|
||||
return await this.get<ProjectPromotionChanges>(
|
||||
`/promotions/projects/${projectId}/changes/${direction}`,
|
||||
{ ...query },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { BaseCommand, EXIT_BLOCKED, EXIT_SOURCE_CHANGED, EXIT_SUCCESS } from '../../base-command';
|
||||
import type { ApplyPackageResult, PromotionBindingPreflight } from '../../client';
|
||||
|
||||
type ReportFlags = { quiet?: boolean; format?: string; json?: boolean; jq?: string };
|
||||
|
||||
/** Shared result handling for Apply and Continue, which return the same statuses. */
|
||||
export abstract class PromotionApplyCommand extends BaseCommand {
|
||||
protected reportApplyResult(
|
||||
result: ApplyPackageResult,
|
||||
flags: ReportFlags,
|
||||
connectionId: string,
|
||||
): void {
|
||||
// `succeed()` ignores --jq, so JSON output for every status goes through `output()`.
|
||||
if (this.isJsonMode(flags)) {
|
||||
this.output(result, flags);
|
||||
this.exit(exitCodeFor(result));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'applied') {
|
||||
const warnings =
|
||||
result.warnings.length > 0 ? ` with ${result.warnings.length} warning(s)` : '';
|
||||
this.succeed(
|
||||
`Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance${warnings}.`,
|
||||
flags,
|
||||
result,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flags.quiet) {
|
||||
this.logToStderr(
|
||||
result.status === 'blocked'
|
||||
? blockedMessage(result, result.preflight, connectionId)
|
||||
: sourceChangedMessage(result),
|
||||
);
|
||||
}
|
||||
this.exit(exitCodeFor(result));
|
||||
}
|
||||
}
|
||||
|
||||
function exitCodeFor(result: ApplyPackageResult): number {
|
||||
if (result.status === 'applied') return EXIT_SUCCESS;
|
||||
return result.status === 'blocked' ? EXIT_BLOCKED : EXIT_SOURCE_CHANGED;
|
||||
}
|
||||
|
||||
function sourceChangedMessage(result: ApplyPackageResult): string {
|
||||
return [
|
||||
'The source changed since the review. Nothing was imported.',
|
||||
`Current source: config ${result.configId}, branch ${result.git.branchName}, commit ${result.git.commitSha}.`,
|
||||
'Review the changes again, then apply the new commit.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function blockedMessage(
|
||||
result: ApplyPackageResult,
|
||||
preflight: PromotionBindingPreflight,
|
||||
connectionId: string,
|
||||
): string {
|
||||
const bindings = (kind: 'credential' | 'variable') =>
|
||||
preflight.missingBindings.filter((binding) => binding.kind === kind).length;
|
||||
|
||||
return [
|
||||
'Apply is blocked. Nothing was imported.',
|
||||
` Missing projects: ${preflight.missingProjects.length}`,
|
||||
` Missing credentials: ${bindings('credential')}`,
|
||||
` Missing variables: ${bindings('variable')}`,
|
||||
` Access requirements: ${preflight.accessRequirements.length}`,
|
||||
` Conflicts: ${preflight.conflicts.length}`,
|
||||
'Run with --json for the details. After you resolve them, run:',
|
||||
` n8n-cli promotion-connection apply-continue ${shellQuote(connectionId)}` +
|
||||
` --expected-config-id=${shellQuote(result.configId)}` +
|
||||
` --expected-branch=${shellQuote(result.git.branchName)}` +
|
||||
` --expected-commit-sha=${shellQuote(result.git.commitSha)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Branch names can contain shell metacharacters such as `;` and `$(`, so the shell must not interpret them. */
|
||||
export function shellQuote(value: string): string {
|
||||
if (/^[\w./@-]+$/.test(value)) return value;
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Args } from '@oclif/core';
|
||||
|
||||
import { PromotionApplyCommand } from './apply-command';
|
||||
import { expectedSourceFlags, toExpectedSource } from './expected-source';
|
||||
import { BaseCommand } from '../../base-command';
|
||||
|
||||
export default class PromotionConnectionApplyContinue extends PromotionApplyCommand {
|
||||
static override description =
|
||||
'Continue an Apply that was blocked on bindings, after they are set up. Pass the config ID, branch, and commit SHA that the blocked Apply reported. Exits 3 when the source changed since then and 4 when bindings are still missing; nothing is imported in either case.';
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> promotion-connection apply-continue conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=<full sha>',
|
||||
];
|
||||
|
||||
static override args = { id: Args.string({ description: 'Connection ID', required: true }) };
|
||||
|
||||
static override flags = {
|
||||
...BaseCommand.baseFlags,
|
||||
...expectedSourceFlags({ required: true }),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { args, flags } = await this.parse(PromotionConnectionApplyContinue);
|
||||
const expectedSource = toExpectedSource(flags);
|
||||
// oclif enforces the required flags, so this only narrows the type.
|
||||
if (!expectedSource) this.error('Pass all three --expected-* flags');
|
||||
await this.execute(async () => {
|
||||
const result = await this.getClient(flags).continueApplyPackage(args.id, expectedSource);
|
||||
this.reportApplyResult(result, flags, args.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,30 @@
|
|||
import { Args } from '@oclif/core';
|
||||
|
||||
import { PromotionApplyCommand } from './apply-command';
|
||||
import { expectedSourceFlags, toExpectedSource } from './expected-source';
|
||||
import { BaseCommand } from '../../base-command';
|
||||
|
||||
export default class PromotionConnectionApply extends BaseCommand {
|
||||
export default class PromotionConnectionApply extends PromotionApplyCommand {
|
||||
static override description =
|
||||
'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Requires the Apply direction to be cloned first.';
|
||||
'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Pass the three --expected-* flags to apply only the reviewed commit. Exits 3 when the source changed since the review and 4 when bindings must be set up first; nothing is imported in either case. Requires the Apply direction to be cloned first.';
|
||||
|
||||
static override examples = ['<%= config.bin %> promotion-connection apply conn-1'];
|
||||
static override examples = [
|
||||
'<%= config.bin %> promotion-connection apply conn-1',
|
||||
'<%= config.bin %> promotion-connection apply conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=<full sha>',
|
||||
];
|
||||
|
||||
static override args = { id: Args.string({ description: 'Connection ID', required: true }) };
|
||||
|
||||
static override flags = { ...BaseCommand.baseFlags };
|
||||
static override flags = {
|
||||
...BaseCommand.baseFlags,
|
||||
...expectedSourceFlags({ required: false }),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { args, flags } = await this.parse(PromotionConnectionApply);
|
||||
await this.execute(async () => {
|
||||
const result = await this.getClient(flags).applyPackage(args.id);
|
||||
this.succeed(
|
||||
`Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance.`,
|
||||
flags,
|
||||
result,
|
||||
);
|
||||
const result = await this.getClient(flags).applyPackage(args.id, toExpectedSource(flags));
|
||||
this.reportApplyResult(result, flags, args.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { Flags } from '@oclif/core';
|
||||
|
||||
import type { PromotionExpectedSource } from '../../client';
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||||
|
||||
/** The server compares the full SHA, so a short one could never match. */
|
||||
export async function parseCommitSha(input: string): Promise<string> {
|
||||
if (!COMMIT_SHA.test(input)) {
|
||||
throw new Error(
|
||||
`Expected a full lowercase commit SHA (40 or 64 hex characters), got "${input}"`,
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/** An unset shell variable arrives as an empty value. Fail here, before any request is sent. */
|
||||
export function parseNonEmpty(label: string) {
|
||||
return async (input: string): Promise<string> => {
|
||||
if (input.trim() === '') throw new Error(`Expected a non-empty ${label}`);
|
||||
return input;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags that pin Apply to the reviewed source. `required: false` makes them
|
||||
* optional as a group: pass all three or none.
|
||||
*/
|
||||
export function expectedSourceFlags({ required }: { required: boolean }) {
|
||||
const group = ['expectedConfigId', 'expectedBranch', 'expectedCommitSha'];
|
||||
const others = (name: string) => (required ? undefined : group.filter((flag) => flag !== name));
|
||||
|
||||
return {
|
||||
expectedConfigId: Flags.string({
|
||||
description: 'Apply config ID of the reviewed source (configs.apply.id)',
|
||||
aliases: ['expected-config-id'],
|
||||
required,
|
||||
dependsOn: others('expectedConfigId'),
|
||||
parse: parseNonEmpty('config ID'),
|
||||
}),
|
||||
expectedBranch: Flags.string({
|
||||
description: 'Branch name of the reviewed source',
|
||||
aliases: ['expected-branch'],
|
||||
required,
|
||||
dependsOn: others('expectedBranch'),
|
||||
parse: parseNonEmpty('branch name'),
|
||||
}),
|
||||
expectedCommitSha: Flags.string({
|
||||
description: 'Full commit SHA of the reviewed source',
|
||||
aliases: ['expected-commit-sha'],
|
||||
required,
|
||||
dependsOn: others('expectedCommitSha'),
|
||||
parse: parseCommitSha,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toExpectedSource(flags: {
|
||||
expectedConfigId?: string;
|
||||
expectedBranch?: string;
|
||||
expectedCommitSha?: string;
|
||||
}): PromotionExpectedSource | undefined {
|
||||
const { expectedConfigId, expectedBranch, expectedCommitSha } = flags;
|
||||
if (
|
||||
expectedConfigId === undefined ||
|
||||
expectedBranch === undefined ||
|
||||
expectedCommitSha === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { configId: expectedConfigId, branchName: expectedBranch, commitSha: expectedCommitSha };
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import { Args } from '@oclif/core';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import { directionArg, toDirection } from './direction';
|
||||
import { BaseCommand } from '../../base-command';
|
||||
import type { PromotionChangesQuery } from '../../client';
|
||||
|
||||
const SORT_FIELDS: ReadonlyArray<NonNullable<PromotionChangesQuery['sort']>> = [
|
||||
'name',
|
||||
'updatedAt',
|
||||
'status',
|
||||
];
|
||||
const SORT_ORDERS: ReadonlyArray<NonNullable<PromotionChangesQuery['order']>> = ['asc', 'desc'];
|
||||
|
||||
export default class PromotionConnectionListChanges extends BaseCommand {
|
||||
static override description =
|
||||
|
|
@ -9,6 +17,7 @@ export default class PromotionConnectionListChanges extends BaseCommand {
|
|||
|
||||
static override examples = [
|
||||
'<%= config.bin %> promotion-connection list-changes proj-abc promote',
|
||||
'<%= config.bin %> promotion-connection list-changes proj-abc apply --sort=updatedAt --order=desc',
|
||||
];
|
||||
|
||||
static override args = {
|
||||
|
|
@ -16,7 +25,18 @@ export default class PromotionConnectionListChanges extends BaseCommand {
|
|||
direction: directionArg,
|
||||
};
|
||||
|
||||
static override flags = { ...BaseCommand.baseFlags };
|
||||
static override flags = {
|
||||
...BaseCommand.baseFlags,
|
||||
search: Flags.string({ description: 'Only list workflows whose name matches this text' }),
|
||||
sort: Flags.string({
|
||||
description: 'Field to sort by (server default: name)',
|
||||
options: [...SORT_FIELDS],
|
||||
}),
|
||||
order: Flags.string({
|
||||
description: 'Sort order (server default: asc)',
|
||||
options: [...SORT_ORDERS],
|
||||
}),
|
||||
};
|
||||
|
||||
async run() {
|
||||
const { args, flags } = await this.parse(PromotionConnectionListChanges);
|
||||
|
|
@ -24,6 +44,11 @@ export default class PromotionConnectionListChanges extends BaseCommand {
|
|||
const result = await this.getClient(flags).listProjectPromotionChanges(
|
||||
args.projectId,
|
||||
toDirection(args.direction),
|
||||
{
|
||||
search: flags.search,
|
||||
sort: SORT_FIELDS.find((field) => field === flags.sort),
|
||||
order: SORT_ORDERS.find((order) => order === flags.order),
|
||||
},
|
||||
);
|
||||
// JSON and jq consumers want the `commitSha` + `changes` wrapper; table
|
||||
// and id-only need the rows themselves, or the array serializes into one
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import ProjectRemoveMember from './commands/project/remove-member';
|
|||
import ProjectUpdate from './commands/project/update';
|
||||
import PromotionConnectionAddProject from './commands/promotion-connection/add-project';
|
||||
import PromotionConnectionApply from './commands/promotion-connection/apply';
|
||||
import PromotionConnectionApplyContinue from './commands/promotion-connection/apply-continue';
|
||||
import PromotionConnectionClone from './commands/promotion-connection/clone';
|
||||
import PromotionConnectionCreate from './commands/promotion-connection/create';
|
||||
import PromotionConnectionDelete from './commands/promotion-connection/delete';
|
||||
|
|
@ -123,6 +124,7 @@ export const commands = {
|
|||
'promotion-connection:promote': PromotionConnectionPromote,
|
||||
'promotion-connection:promote-selection': PromotionConnectionPromoteSelection,
|
||||
'promotion-connection:apply': PromotionConnectionApply,
|
||||
'promotion-connection:apply-continue': PromotionConnectionApplyContinue,
|
||||
|
||||
'credential:list': CredentialList,
|
||||
'credential:get': CredentialGet,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user