Skip to content

Commit b8a2ff8

Browse files
feat: openfn merge command (#1035)
* feat: init merge command * tests: update * feat: add removeUnmapped option * feat: accept workflow-mappings as cli arguments * tests: cli object parsing * tests: merge handler test * Merge: fix pathing and add a basic integration test (#1050) * basic integration test * remove log line * remove more logging * version: cli@1.16.0 --------- Co-authored-by: Joe Clark <jclark@openfn.org>
1 parent 98b5ee6 commit b8a2ff8

17 files changed

Lines changed: 514 additions & 12 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import test from 'ava';
2+
import { rm, mkdir, writeFile, readFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import run from '../src/run';
5+
6+
const mainYaml = `
7+
id: 8dbc4349-52b4-4bf2-be10-fdf06da52c46
8+
name: hello-world
9+
description: Simple test project to test sandboxing and merging
10+
project_credentials: []
11+
collections: []
12+
inserted_at: 2025-10-07T09:47:41Z
13+
updated_at: 2025-10-07T09:47:41Z
14+
env: null
15+
color: null
16+
concurrency: null
17+
scheduled_deletion: null
18+
parent_id: null
19+
history_retention_period: null
20+
allow_support_access: false
21+
dataclip_retention_period: null
22+
requires_mfa: false
23+
retention_policy: retain_all
24+
version_history: []
25+
workflows:
26+
- name: my workflow
27+
id: 0afbefab-5824-4911-aaae-a19f20106dec
28+
concurrency: null
29+
inserted_at: 2025-10-07T10:00:23Z
30+
updated_at: 2025-10-07T10:00:29Z
31+
deleted_at: null
32+
lock_version: 2
33+
jobs:
34+
- name: Transform data
35+
body: |
36+
// TODO
37+
adaptor: "@openfn/language-common@latest"
38+
id: b8b780f3-98dd-4244-880b-e534d8f24547
39+
project_credential_id: null
40+
triggers:
41+
- type: webhook
42+
enabled: true
43+
id: 3b4a47c0-7242-4f0c-8886-838e34762654
44+
edges:
45+
- id: 33dce70f-047f-4508-82fd-950eb508519b
46+
target_job_id: b8b780f3-98dd-4244-880b-e534d8f24547
47+
enabled: true
48+
source_trigger_id: 3b4a47c0-7242-4f0c-8886-838e34762654
49+
condition_type: always
50+
`;
51+
52+
const stagingYaml = `
53+
id: 5deddbfa-c63f-4dbc-98b5-a49d3395a488
54+
name: hello-world-staging
55+
description: "simulate a staging project "
56+
project_credentials: []
57+
collections: []
58+
inserted_at: 2025-10-07T10:00:13Z
59+
updated_at: 2025-10-07T10:00:13Z
60+
env: null
61+
color: null
62+
concurrency: null
63+
scheduled_deletion: null
64+
parent_id: null
65+
history_retention_period: null
66+
allow_support_access: false
67+
dataclip_retention_period: null
68+
requires_mfa: false
69+
retention_policy: retain_all
70+
version_history: []
71+
workflows:
72+
- name: my workflow
73+
id: 9e2cc86a-8896-4a5a-9467-9c4128207fd3
74+
concurrency: null
75+
inserted_at: 2025-10-07T10:00:36Z
76+
updated_at: 2025-10-07T10:00:53Z
77+
deleted_at: null
78+
lock_version: 3
79+
jobs:
80+
- name: Transform data
81+
body: log('hello world')
82+
adaptor: "@openfn/language-common@latest"
83+
id: 8d627978-ebb9-4fb2-8cda-9b31c10c963e
84+
project_credential_id: null
85+
triggers:
86+
- type: webhook
87+
enabled: true
88+
id: 7bb476cc-0292-4573-89d0-b13417bc648e
89+
edges:
90+
- id: 4c68d22a-4ba7-4d8f-8103-6f4f15c4e7d2
91+
target_job_id: 8d627978-ebb9-4fb2-8cda-9b31c10c963e
92+
enabled: true
93+
source_trigger_id: 7bb476cc-0292-4573-89d0-b13417bc648e
94+
condition_type: always
95+
`;
96+
const projectsPath = path.resolve('tmp/project');
97+
98+
test.before(async () => {
99+
// await rm('tmp/project', { recursive: true });
100+
await mkdir('tmp/project/.projects', { recursive: true });
101+
102+
await writeFile('tmp/project/openfn.yaml', '');
103+
await writeFile('tmp/project/.projects/main@app.openfn.org.yaml', mainYaml);
104+
await writeFile(
105+
'tmp/project/.projects/staging@app.openfn.org.yaml',
106+
stagingYaml
107+
);
108+
});
109+
110+
// checkout a project from a yaml file
111+
test.serial('Checkout a project', async (t) => {
112+
await run(`openfn checkout hello-world -p ${projectsPath}`);
113+
114+
// check workflow.yaml
115+
const workflowYaml = await readFile(
116+
path.resolve(projectsPath, 'workflows/my-workflow/my-workflow.yaml'),
117+
'utf8'
118+
);
119+
t.is(
120+
workflowYaml,
121+
`id: my-workflow
122+
name: my workflow
123+
options: {}
124+
steps:
125+
- id: trigger
126+
type: webhook
127+
next:
128+
transform-data:
129+
disabled: false
130+
condition: true
131+
openfn:
132+
uuid: 33dce70f-047f-4508-82fd-950eb508519b
133+
- id: transform-data
134+
name: Transform data
135+
adaptor: "@openfn/language-common@latest"
136+
expression: ./transform-data.js
137+
`
138+
);
139+
140+
const expr = await readFile(
141+
path.resolve(projectsPath, 'workflows/my-workflow/transform-data.js'),
142+
'utf8'
143+
);
144+
t.is(expr.trim(), '// TODO');
145+
});
146+
147+
// requires the prior test to run
148+
test.serial('merge a project', async (t) => {
149+
const readStep = () =>
150+
readFile(
151+
path.resolve(projectsPath, 'workflows/my-workflow/transform-data.js'),
152+
'utf8'
153+
).then((str) => str.trim());
154+
155+
// assert the intial step code
156+
const initial = await readStep();
157+
t.is(initial, '// TODO');
158+
159+
// Run the merge
160+
await run(`openfn merge hello-world-staging -p ${projectsPath}`);
161+
162+
// Check the step is updated
163+
const merged = await readStep();
164+
t.is(merged, "log('hello world')");
165+
});

packages/cli/CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# @openfn/cli
22

3-
## 1.15.2
3+
## 1.16.0
4+
5+
### Minor Changes
6+
7+
- Support merge command, to merge two projects while preserving UUIDs
48

59
### Patch Changes
610

7-
- Updated dependencies [84bebf4]
8-
- @openfn/runtime@1.7.3
11+
- Updated dependencies
12+
- @openfn/project@0.4.1
913

1014
## 1.15.1
1115

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/cli",
3-
"version": "1.15.2",
3+
"version": "1.16.0",
44
"description": "CLI devtools for the OpenFn toolchain",
55
"engines": {
66
"node": ">=18",

packages/cli/src/checkout/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import fs from 'fs';
66
import { rimraf } from 'rimraf';
77

88
const checkoutHandler = async (options: CheckoutOptions, logger: Logger) => {
9-
const commandPath = path.resolve(process.cwd(), options.projectPath ?? '.');
9+
const commandPath = path.resolve(options.projectPath ?? '.');
1010
const workspace = new Workspace(commandPath);
1111
if (!workspace.valid) {
1212
logger.error('Command was run in an invalid openfn workspace');
@@ -17,7 +17,7 @@ const checkoutHandler = async (options: CheckoutOptions, logger: Logger) => {
1717
const switchProject = workspace.get(options.projectName);
1818
if (!switchProject) {
1919
logger.error(
20-
`Project with id ${options.projectName} not found in the workspace`
20+
`Project with id/name ${options.projectName} not found in the workspace`
2121
);
2222
return;
2323
}

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { install as installCommand, repo as repoCommand } from './repo/command';
1515
import testCommand from './test/command';
1616
import projectsCommand from './projects/command';
1717
import checkoutCommand from './checkout/command';
18+
import mergeCommand from './merge/command';
1819

1920
const y = yargs(hideBin(process.argv));
2021

@@ -34,6 +35,7 @@ export const cmd = y
3435
.command(pullCommand as any)
3536
.command(projectsCommand)
3637
.command(checkoutCommand)
38+
.command(mergeCommand)
3739
.command({
3840
command: 'version',
3941
describe:

packages/cli/src/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import metadata from './metadata/handler';
1111
import pull from './pull/handler';
1212
import projects from './projects/handler';
1313
import checkout from './checkout/handler';
14+
import merge from './merge/handler';
1415
import { clean, install, pwd, list } from './repo/handler';
1516

1617
import createLogger, { CLI, Logger } from './util/logger';
@@ -34,6 +35,7 @@ export type CommandList =
3435
| 'pull'
3536
| 'projects'
3637
| 'checkout'
38+
| 'merge'
3739
| 'repo-clean'
3840
| 'repo-install'
3941
| 'repo-list'
@@ -53,6 +55,7 @@ const handlers = {
5355
pull,
5456
projects,
5557
checkout,
58+
merge,
5659
['collections-get']: collections.get,
5760
['collections-set']: collections.set,
5861
['collections-remove']: collections.remove,

packages/cli/src/merge/command.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import yargs from 'yargs';
2+
import { Opts } from '../options';
3+
import { ensure, build } from '../util/command-builders';
4+
import * as o from '../options';
5+
6+
export type MergeOptions = Required<
7+
Pick<
8+
Opts,
9+
| 'command'
10+
| 'projectName'
11+
| 'projectPath'
12+
| 'removeUnmapped'
13+
| 'workflowMappings'
14+
>
15+
>;
16+
17+
const options = [
18+
o.projectName,
19+
o.projectPath,
20+
o.removeUnmapped,
21+
o.workflowMappings,
22+
];
23+
24+
const mergeCommand: yargs.CommandModule = {
25+
command: 'merge [project-name]',
26+
describe: 'Merges the specified project into the checked out project',
27+
handler: ensure('merge', options),
28+
builder: (yargs) => build(options, yargs),
29+
};
30+
31+
export default mergeCommand;

packages/cli/src/merge/handler.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Project, { Workspace } from '@openfn/project';
2+
import path from 'path';
3+
import type { Logger } from '../util/logger';
4+
import type { MergeOptions } from './command';
5+
import { promises as fs } from 'fs';
6+
import checkoutHandler from '../checkout/handler';
7+
8+
const mergeHandler = async (options: MergeOptions, logger: Logger) => {
9+
const commandPath = path.resolve(options.projectPath ?? '.');
10+
const workspace = new Workspace(commandPath);
11+
if (!workspace.valid) {
12+
logger.error('Command was run in an invalid openfn workspace');
13+
return;
14+
}
15+
16+
const checkedProject = workspace.getActiveProject();
17+
if (!checkedProject) {
18+
logger.error(`No project currently checked out`);
19+
return;
20+
}
21+
22+
const mProject = workspace.get(options.projectName);
23+
if (!mProject) {
24+
logger.error(
25+
`Project with id/name ${options.projectName} not found in the workspace`
26+
);
27+
return;
28+
}
29+
30+
if (checkedProject.name === mProject.name) {
31+
logger.error('Merging into the same project not allowed');
32+
return;
33+
}
34+
35+
if (!checkedProject.name) {
36+
logger.error('The checked out project has no name/id');
37+
return;
38+
}
39+
40+
const finalPath = workspace.getProjectPath(checkedProject.name);
41+
if (!finalPath) {
42+
logger.error('Path to checked out project not found.');
43+
return;
44+
}
45+
46+
// TODO pick options from the terminal
47+
const final = Project.merge(mProject, checkedProject, {
48+
removeUnmapped: options.removeUnmapped,
49+
workflowMappings: options.workflowMappings,
50+
});
51+
const yaml = final.serialize('state', { format: 'yaml' });
52+
await fs.writeFile(finalPath, yaml);
53+
54+
// Checkout after merge. to unwrap updated files into filesystem
55+
await checkoutHandler(
56+
{
57+
command: 'checkout',
58+
projectPath: commandPath,
59+
projectName: final.name || '',
60+
},
61+
logger
62+
);
63+
logger.success(
64+
`Project ${mProject.name} has been merged into Project ${checkedProject.name} successfully`
65+
);
66+
};
67+
68+
export default mergeHandler;

packages/cli/src/options.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ensureLogOpts,
99
LogLevel,
1010
} from './util';
11+
import getCLIOptionObject from './util/get-cli-option-object';
1112

1213
// Central type definition for the main options
1314
// This represents the types coming out of yargs,
@@ -64,6 +65,9 @@ export type Opts = {
6465
trace?: boolean;
6566
useAdaptorsMonorepo?: boolean;
6667
workflow: string;
68+
// merge options
69+
removeUnmapped?: boolean | undefined;
70+
workflowMappings?: Record<string, string> | undefined;
6771

6872
// deprecated
6973
workflowPath?: string;
@@ -581,3 +585,23 @@ export const workflow: CLIOption = {
581585
description: 'Name of the workflow to execute',
582586
},
583587
};
588+
589+
// merge options
590+
export const removeUnmapped: CLIOption = {
591+
name: 'remove-unmapped',
592+
yargs: {
593+
boolean: true,
594+
description:
595+
"Removes all workflows that didn't get mapped from the final project after merge",
596+
},
597+
};
598+
599+
export const workflowMappings: CLIOption = {
600+
name: 'workflow-mappings',
601+
yargs: {
602+
type: 'string',
603+
coerce: getCLIOptionObject,
604+
description:
605+
'A manual object mapping of which workflows in source and target should be matched for a merge.',
606+
},
607+
};

0 commit comments

Comments
 (0)