Skip to content

Commit ee84cbb

Browse files
committed
feat(create-cli): add CI/CD setup step
1 parent f02b9de commit ee84cbb

File tree

5 files changed

+310
-10
lines changed

5 files changed

+310
-10
lines changed

packages/create-cli/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import yargs from 'yargs';
33
import { hideBin } from 'yargs/helpers';
44
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
55
import {
6+
CI_PROVIDERS,
67
CONFIG_FILE_FORMATS,
78
type PluginSetupBinding,
89
SETUP_MODES,
@@ -39,6 +40,11 @@ const argv = await yargs(hideBin(process.argv))
3940
choices: SETUP_MODES,
4041
describe: 'Setup mode (default: auto-detected from project)',
4142
})
43+
.option('ci', {
44+
type: 'string',
45+
choices: CI_PROVIDERS,
46+
describe: 'CI/CD integration (github, gitlab, or skip)',
47+
})
4248
.check(parsed => {
4349
validatePluginSlugs(bindings, parsed.plugins);
4450
return true;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { select } from '@inquirer/prompts';
2+
import { logger } from '@code-pushup/utils';
3+
import type { CiProvider, CliArgs, ConfigContext, Tree } from './types.js';
4+
5+
const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml';
6+
const GITLAB_CONFIG_PATH = '.gitlab-ci.yml';
7+
const GITLAB_CONFIG_SEPARATE_PATH = 'code-pushup.gitlab-ci.yml';
8+
9+
export async function promptCiProvider(cliArgs: CliArgs): Promise<CiProvider> {
10+
if (isCiProvider(cliArgs.ci)) {
11+
return cliArgs.ci;
12+
}
13+
if (cliArgs.yes) {
14+
return 'skip';
15+
}
16+
return select<CiProvider>({
17+
message: 'CI/CD integration:',
18+
choices: [
19+
{ name: 'GitHub Actions', value: 'github' },
20+
{ name: 'GitLab CI/CD', value: 'gitlab' },
21+
{ name: 'Skip', value: 'skip' },
22+
],
23+
default: 'skip',
24+
});
25+
}
26+
27+
export async function resolveCi(
28+
tree: Tree,
29+
provider: CiProvider,
30+
context: ConfigContext,
31+
): Promise<void> {
32+
switch (provider) {
33+
case 'github':
34+
await writeGitHubWorkflow(tree, context);
35+
break;
36+
case 'gitlab':
37+
await writeGitLabConfig(tree);
38+
break;
39+
case 'skip':
40+
break;
41+
}
42+
}
43+
44+
async function writeGitHubWorkflow(
45+
tree: Tree,
46+
context: ConfigContext,
47+
): Promise<void> {
48+
await tree.write(GITHUB_WORKFLOW_PATH, generateGitHubYaml(context));
49+
}
50+
51+
function generateGitHubYaml({ mode, tool }: ConfigContext): string {
52+
const lines = [
53+
'name: Code PushUp',
54+
'',
55+
'on:',
56+
' push:',
57+
' branches: [main]',
58+
' pull_request:',
59+
' branches: [main]',
60+
'',
61+
'permissions:',
62+
' contents: read',
63+
' actions: read',
64+
' pull-requests: write',
65+
'',
66+
'jobs:',
67+
' code-pushup:',
68+
' runs-on: ubuntu-latest',
69+
' steps:',
70+
' - name: Clone repository',
71+
' uses: actions/checkout@v5',
72+
' - name: Set up Node.js',
73+
' uses: actions/setup-node@v6',
74+
' - name: Install dependencies',
75+
' run: npm ci',
76+
' - name: Code PushUp',
77+
' uses: code-pushup/github-action@v0',
78+
...(mode === 'monorepo' && tool != null
79+
? [' with:', ` monorepo: ${tool}`]
80+
: []),
81+
];
82+
return `${lines.join('\n')}\n`;
83+
}
84+
85+
async function writeGitLabConfig(tree: Tree): Promise<void> {
86+
const filePath = await resolveGitLabFilePath(tree);
87+
await tree.write(filePath, generateGitLabYaml());
88+
89+
if (filePath === GITLAB_CONFIG_SEPARATE_PATH) {
90+
logger.warn(
91+
[
92+
`Add the following to your ${GITLAB_CONFIG_PATH}:`,
93+
' include:',
94+
` - local: ${GITLAB_CONFIG_SEPARATE_PATH}`,
95+
].join('\n'),
96+
);
97+
}
98+
}
99+
100+
function generateGitLabYaml(): string {
101+
const lines = [
102+
'workflow:',
103+
' rules:',
104+
' - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH',
105+
" - if: $CI_PIPELINE_SOURCE == 'merge_request_event'",
106+
'',
107+
'include:',
108+
' - https://gitlab.com/code-pushup/gitlab-pipelines-template/-/raw/latest/code-pushup.yml',
109+
];
110+
return `${lines.join('\n')}\n`;
111+
}
112+
113+
async function resolveGitLabFilePath(tree: Tree): Promise<string> {
114+
if (await tree.exists(GITLAB_CONFIG_PATH)) {
115+
return GITLAB_CONFIG_SEPARATE_PATH;
116+
}
117+
return GITLAB_CONFIG_PATH;
118+
}
119+
120+
function isCiProvider(value: string | undefined): value is CiProvider {
121+
return value === 'github' || value === 'gitlab' || value === 'skip';
122+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { select } from '@inquirer/prompts';
2+
import { vol } from 'memfs';
3+
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
4+
import { logger } from '@code-pushup/utils';
5+
import { promptCiProvider, resolveCi } from './ci.js';
6+
import type { ConfigContext } from './types.js';
7+
import { createTree } from './virtual-fs.js';
8+
9+
vi.mock('@inquirer/prompts', () => ({
10+
select: vi.fn(),
11+
}));
12+
13+
describe('promptCiProvider', () => {
14+
it.each(['github', 'gitlab', 'skip'] as const)(
15+
'should return %j when --ci %s is provided',
16+
async ci => {
17+
await expect(promptCiProvider({ ci })).resolves.toBe(ci);
18+
expect(select).not.toHaveBeenCalled();
19+
},
20+
);
21+
22+
it('should return "skip" when --yes is provided', async () => {
23+
await expect(promptCiProvider({ yes: true })).resolves.toBe('skip');
24+
expect(select).not.toHaveBeenCalled();
25+
});
26+
27+
it('should prompt interactively when no CLI arg or --yes', async () => {
28+
vi.mocked(select).mockResolvedValue('github');
29+
30+
await expect(promptCiProvider({})).resolves.toBe('github');
31+
expect(select).toHaveBeenCalledWith(
32+
expect.objectContaining({
33+
message: 'CI/CD integration:',
34+
default: 'skip',
35+
}),
36+
);
37+
});
38+
});
39+
40+
describe('resolveCi', () => {
41+
const STANDALONE_CONTEXT: ConfigContext = { mode: 'standalone', tool: null };
42+
43+
describe('GitHub Actions', () => {
44+
it('should create workflow without monorepo input in standalone mode', async () => {
45+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
46+
const tree = createTree(MEMFS_VOLUME);
47+
48+
await resolveCi(tree, 'github', STANDALONE_CONTEXT);
49+
await expect(tree.read('.github/workflows/code-pushup.yml')).resolves
50+
.toMatchInlineSnapshot(`
51+
"name: Code PushUp
52+
53+
on:
54+
push:
55+
branches: [main]
56+
pull_request:
57+
branches: [main]
58+
59+
permissions:
60+
contents: read
61+
actions: read
62+
pull-requests: write
63+
64+
jobs:
65+
code-pushup:
66+
runs-on: ubuntu-latest
67+
steps:
68+
- name: Clone repository
69+
uses: actions/checkout@v5
70+
- name: Set up Node.js
71+
uses: actions/setup-node@v6
72+
- name: Install dependencies
73+
run: npm ci
74+
- name: Code PushUp
75+
uses: code-pushup/github-action@v0
76+
"
77+
`);
78+
});
79+
80+
it('should create workflow with monorepo input when in monorepo mode', async () => {
81+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
82+
const tree = createTree(MEMFS_VOLUME);
83+
84+
await resolveCi(tree, 'github', { mode: 'monorepo', tool: 'nx' });
85+
await expect(tree.read('.github/workflows/code-pushup.yml')).resolves
86+
.toMatchInlineSnapshot(`
87+
"name: Code PushUp
88+
89+
on:
90+
push:
91+
branches: [main]
92+
pull_request:
93+
branches: [main]
94+
95+
permissions:
96+
contents: read
97+
actions: read
98+
pull-requests: write
99+
100+
jobs:
101+
code-pushup:
102+
runs-on: ubuntu-latest
103+
steps:
104+
- name: Clone repository
105+
uses: actions/checkout@v5
106+
- name: Set up Node.js
107+
uses: actions/setup-node@v6
108+
- name: Install dependencies
109+
run: npm ci
110+
- name: Code PushUp
111+
uses: code-pushup/github-action@v0
112+
with:
113+
monorepo: nx
114+
"
115+
`);
116+
});
117+
});
118+
119+
describe('GitLab CI/CD', () => {
120+
it('should create .gitlab-ci.yml when no file exists', async () => {
121+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
122+
const tree = createTree(MEMFS_VOLUME);
123+
124+
await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT);
125+
126+
expect(tree.listChanges()).toPartiallyContain({
127+
path: '.gitlab-ci.yml',
128+
type: 'CREATE',
129+
});
130+
});
131+
132+
it('should create separate file and log include instruction when .gitlab-ci.yml already exists', async () => {
133+
vol.fromJSON(
134+
{
135+
'package.json': '{}',
136+
'.gitlab-ci.yml': 'stages:\n - test\n',
137+
},
138+
MEMFS_VOLUME,
139+
);
140+
const tree = createTree(MEMFS_VOLUME);
141+
142+
await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT);
143+
144+
expect(tree.listChanges()).toPartiallyContain({
145+
path: 'code-pushup.gitlab-ci.yml',
146+
type: 'CREATE',
147+
});
148+
expect(tree.listChanges()).not.toPartiallyContain({
149+
path: '.gitlab-ci.yml',
150+
});
151+
expect(logger.warn).toHaveBeenCalledWith(
152+
expect.stringContaining('code-pushup.gitlab-ci.yml'),
153+
);
154+
});
155+
});
156+
157+
describe('skip', () => {
158+
it('should make no changes when provider is skip', async () => {
159+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
160+
const tree = createTree(MEMFS_VOLUME);
161+
162+
await resolveCi(tree, 'skip', STANDALONE_CONTEXT);
163+
164+
expect(tree.listChanges()).toStrictEqual([]);
165+
});
166+
});
167+
});

packages/create-cli/src/lib/setup/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PluginMeta } from '@code-pushup/models';
22
import type { MonorepoTool } from '@code-pushup/utils';
33

4+
export const CI_PROVIDERS = ['github', 'gitlab', 'skip'] as const;
5+
export type CiProvider = (typeof CI_PROVIDERS)[number];
6+
47
export const CONFIG_FILE_FORMATS = ['ts', 'js', 'mjs'] as const;
58
export type ConfigFileFormat = (typeof CONFIG_FILE_FORMATS)[number];
69

@@ -16,6 +19,7 @@ export type CliArgs = {
1619
'config-format'?: string;
1720
mode?: SetupMode;
1821
plugins?: string[];
22+
ci?: string;
1923
'target-dir'?: string;
2024
[key: string]: unknown;
2125
};

packages/create-cli/src/lib/setup/wizard.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
logger,
88
toUnixPath,
99
} from '@code-pushup/utils';
10+
import { promptCiProvider, resolveCi } from './ci.js';
1011
import {
1112
computeRelativePresetImport,
1213
generateConfigSource,
@@ -48,39 +49,39 @@ export async function runSetupWizard(
4849
): Promise<void> {
4950
const targetDir = cliArgs['target-dir'] ?? process.cwd();
5051

51-
const { mode, tool } = await promptSetupMode(targetDir, cliArgs);
52+
const context = await promptSetupMode(targetDir, cliArgs);
5253
const selectedBindings = await promptPluginSelection(
5354
bindings,
5455
targetDir,
5556
cliArgs,
5657
);
57-
5858
const format = await promptConfigFormat(targetDir, cliArgs);
59-
const packageJson = await readPackageJson(targetDir);
60-
const isEsm = packageJson.type === 'module';
61-
const configFilename = resolveFilename('code-pushup.config', format, isEsm);
59+
const ciProvider = await promptCiProvider(cliArgs);
6260

6361
const resolved: ScopedPluginResult[] = await asyncSequential(
6462
selectedBindings,
6563
async binding => ({
6664
scope: binding.scope ?? 'project',
67-
result: await resolveBinding(binding, cliArgs, { mode, tool }),
65+
result: await resolveBinding(binding, cliArgs, context),
6866
}),
6967
);
7068

71-
const gitRoot = await getGitRoot();
72-
const tree = createTree(gitRoot);
69+
const packageJson = await readPackageJson(targetDir);
70+
const isEsm = packageJson.type === 'module';
71+
const configFilename = resolveFilename('code-pushup.config', format, isEsm);
7372

73+
const tree = createTree(await getGitRoot());
7474
const writeContext: WriteContext = { tree, format, configFilename, isEsm };
7575

76-
await (mode === 'monorepo' && tool != null
77-
? writeMonorepoConfigs(writeContext, resolved, targetDir, tool)
76+
await (context.mode === 'monorepo' && context.tool != null
77+
? writeMonorepoConfigs(writeContext, resolved, targetDir, context.tool)
7878
: writeStandaloneConfig(
7979
writeContext,
8080
resolved.map(r => r.result),
8181
));
8282

8383
await resolveGitignore(tree);
84+
await resolveCi(tree, ciProvider, context);
8485

8586
logChanges(tree.listChanges());
8687

0 commit comments

Comments
 (0)