Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/create-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
import {
CI_PROVIDERS,
CONFIG_FILE_FORMATS,
type PluginSetupBinding,
SETUP_MODES,
Expand Down Expand Up @@ -39,6 +40,11 @@ const argv = await yargs(hideBin(process.argv))
choices: SETUP_MODES,
describe: 'Setup mode (default: auto-detected from project)',
})
.option('ci', {
type: 'string',
choices: CI_PROVIDERS,
describe: 'CI/CD integration (github, gitlab, or skip)',
})
.check(parsed => {
validatePluginSlugs(bindings, parsed.plugins);
return true;
Expand Down
122 changes: 122 additions & 0 deletions packages/create-cli/src/lib/setup/ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { select } from '@inquirer/prompts';
import { logger } from '@code-pushup/utils';
import type { CiProvider, CliArgs, ConfigContext, Tree } from './types.js';

const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml';
const GITLAB_CONFIG_PATH = '.gitlab-ci.yml';
const GITLAB_CONFIG_SEPARATE_PATH = 'code-pushup.gitlab-ci.yml';
Comment thread
hanna-skryl marked this conversation as resolved.
Outdated

export async function promptCiProvider(cliArgs: CliArgs): Promise<CiProvider> {
if (isCiProvider(cliArgs.ci)) {
return cliArgs.ci;
}
if (cliArgs.yes) {
return 'skip';
}
return select<CiProvider>({
message: 'CI/CD integration:',
choices: [
{ name: 'GitHub Actions', value: 'github' },
{ name: 'GitLab CI/CD', value: 'gitlab' },
{ name: 'Skip', value: 'skip' },
Comment thread
hanna-skryl marked this conversation as resolved.
Outdated
],
default: 'skip',
});
}

export async function resolveCi(
tree: Tree,
provider: CiProvider,
context: ConfigContext,
): Promise<void> {
switch (provider) {
case 'github':
await writeGitHubWorkflow(tree, context);
break;
case 'gitlab':
await writeGitLabConfig(tree);
break;
case 'skip':
break;
}
}

async function writeGitHubWorkflow(
tree: Tree,
context: ConfigContext,
): Promise<void> {
await tree.write(GITHUB_WORKFLOW_PATH, generateGitHubYaml(context));
}

function generateGitHubYaml({ mode, tool }: ConfigContext): string {
const lines = [
'name: Code PushUp',
'',
'on:',
' push:',
' branches: [main]',
' pull_request:',
' branches: [main]',
'',
Comment thread
hanna-skryl marked this conversation as resolved.
'permissions:',
' contents: read',
' actions: read',
' pull-requests: write',
'',
'jobs:',
' code-pushup:',
' runs-on: ubuntu-latest',
' steps:',
Comment thread
hanna-skryl marked this conversation as resolved.
' - name: Clone repository',
' uses: actions/checkout@v5',
' - name: Set up Node.js',
' uses: actions/setup-node@v6',
' - name: Install dependencies',
' run: npm ci',
' - name: Code PushUp',
' uses: code-pushup/github-action@v0',
...(mode === 'monorepo' && tool != null
? [' with:', ` monorepo: ${tool}`]
: []),
];
return `${lines.join('\n')}\n`;
}

async function writeGitLabConfig(tree: Tree): Promise<void> {
const filePath = await resolveGitLabFilePath(tree);
await tree.write(filePath, generateGitLabYaml());

if (filePath === GITLAB_CONFIG_SEPARATE_PATH) {
logger.warn(
[
`Add the following to your ${GITLAB_CONFIG_PATH}:`,
' include:',
` - local: ${GITLAB_CONFIG_SEPARATE_PATH}`,
].join('\n'),
);
}
}
Comment thread
hanna-skryl marked this conversation as resolved.

function generateGitLabYaml(): string {
const lines = [
'workflow:',
' rules:',
' - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH',
" - if: $CI_PIPELINE_SOURCE == 'merge_request_event'",
'',
'include:',
' - https://gitlab.com/code-pushup/gitlab-pipelines-template/-/raw/latest/code-pushup.yml',
];
return `${lines.join('\n')}\n`;
}

async function resolveGitLabFilePath(tree: Tree): Promise<string> {
if (await tree.exists(GITLAB_CONFIG_PATH)) {
return GITLAB_CONFIG_SEPARATE_PATH;
}
return GITLAB_CONFIG_PATH;
}

function isCiProvider(value: string | undefined): value is CiProvider {
return value === 'github' || value === 'gitlab' || value === 'skip';
}
Comment thread
hanna-skryl marked this conversation as resolved.
167 changes: 167 additions & 0 deletions packages/create-cli/src/lib/setup/ci.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { select } from '@inquirer/prompts';
import { vol } from 'memfs';
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
import { logger } from '@code-pushup/utils';
import { promptCiProvider, resolveCi } from './ci.js';
import type { ConfigContext } from './types.js';
import { createTree } from './virtual-fs.js';

vi.mock('@inquirer/prompts', () => ({
select: vi.fn(),
}));

describe('promptCiProvider', () => {
it.each(['github', 'gitlab', 'skip'] as const)(
'should return %j when --ci %s is provided',
async ci => {
await expect(promptCiProvider({ ci })).resolves.toBe(ci);
expect(select).not.toHaveBeenCalled();
},
);

it('should return "skip" when --yes is provided', async () => {
await expect(promptCiProvider({ yes: true })).resolves.toBe('skip');
expect(select).not.toHaveBeenCalled();
});

it('should prompt interactively when no CLI arg or --yes', async () => {
vi.mocked(select).mockResolvedValue('github');

await expect(promptCiProvider({})).resolves.toBe('github');
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: 'CI/CD integration:',
default: 'skip',
}),
);
});
});

describe('resolveCi', () => {
const STANDALONE_CONTEXT: ConfigContext = { mode: 'standalone', tool: null };

describe('GitHub Actions', () => {
it('should create workflow without monorepo input in standalone mode', async () => {
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
const tree = createTree(MEMFS_VOLUME);

await resolveCi(tree, 'github', STANDALONE_CONTEXT);
await expect(tree.read('.github/workflows/code-pushup.yml')).resolves
.toMatchInlineSnapshot(`
"name: Code PushUp

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read
actions: read
pull-requests: write

jobs:
code-pushup:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v6
- name: Install dependencies
run: npm ci
- name: Code PushUp
uses: code-pushup/github-action@v0
"
`);
});

it('should create workflow with monorepo input when in monorepo mode', async () => {
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
const tree = createTree(MEMFS_VOLUME);

await resolveCi(tree, 'github', { mode: 'monorepo', tool: 'nx' });
await expect(tree.read('.github/workflows/code-pushup.yml')).resolves
.toMatchInlineSnapshot(`
"name: Code PushUp

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read
actions: read
pull-requests: write

jobs:
code-pushup:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v6
- name: Install dependencies
run: npm ci
- name: Code PushUp
uses: code-pushup/github-action@v0
with:
monorepo: nx
"
`);
});
});

describe('GitLab CI/CD', () => {
it('should create .gitlab-ci.yml when no file exists', async () => {
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
const tree = createTree(MEMFS_VOLUME);

await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT);

expect(tree.listChanges()).toPartiallyContain({
path: '.gitlab-ci.yml',
type: 'CREATE',
});
});

it('should create separate file and log include instruction when .gitlab-ci.yml already exists', async () => {
vol.fromJSON(
{
'package.json': '{}',
'.gitlab-ci.yml': 'stages:\n - test\n',
},
MEMFS_VOLUME,
);
const tree = createTree(MEMFS_VOLUME);

await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT);

expect(tree.listChanges()).toPartiallyContain({
path: 'code-pushup.gitlab-ci.yml',
type: 'CREATE',
});
expect(tree.listChanges()).not.toPartiallyContain({
path: '.gitlab-ci.yml',
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('code-pushup.gitlab-ci.yml'),
);
});
});

describe('skip', () => {
it('should make no changes when provider is skip', async () => {
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
const tree = createTree(MEMFS_VOLUME);

await resolveCi(tree, 'skip', STANDALONE_CONTEXT);

expect(tree.listChanges()).toStrictEqual([]);
});
});
});
4 changes: 4 additions & 0 deletions packages/create-cli/src/lib/setup/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { PluginMeta } from '@code-pushup/models';
import type { MonorepoTool } from '@code-pushup/utils';

export const CI_PROVIDERS = ['github', 'gitlab', 'skip'] as const;
export type CiProvider = (typeof CI_PROVIDERS)[number];

export const CONFIG_FILE_FORMATS = ['ts', 'js', 'mjs'] as const;
export type ConfigFileFormat = (typeof CONFIG_FILE_FORMATS)[number];

Expand All @@ -16,6 +19,7 @@ export type CliArgs = {
'config-format'?: string;
mode?: SetupMode;
plugins?: string[];
ci?: string;
'target-dir'?: string;
[key: string]: unknown;
};
Expand Down
21 changes: 11 additions & 10 deletions packages/create-cli/src/lib/setup/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
logger,
toUnixPath,
} from '@code-pushup/utils';
import { promptCiProvider, resolveCi } from './ci.js';
import {
computeRelativePresetImport,
generateConfigSource,
Expand Down Expand Up @@ -48,39 +49,39 @@ export async function runSetupWizard(
): Promise<void> {
const targetDir = cliArgs['target-dir'] ?? process.cwd();

const { mode, tool } = await promptSetupMode(targetDir, cliArgs);
const context = await promptSetupMode(targetDir, cliArgs);
const selectedBindings = await promptPluginSelection(
bindings,
targetDir,
cliArgs,
);

const format = await promptConfigFormat(targetDir, cliArgs);
const packageJson = await readPackageJson(targetDir);
const isEsm = packageJson.type === 'module';
const configFilename = resolveFilename('code-pushup.config', format, isEsm);
const ciProvider = await promptCiProvider(cliArgs);

const resolved: ScopedPluginResult[] = await asyncSequential(
selectedBindings,
async binding => ({
scope: binding.scope ?? 'project',
result: await resolveBinding(binding, cliArgs, { mode, tool }),
result: await resolveBinding(binding, cliArgs, context),
}),
);

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

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

await (mode === 'monorepo' && tool != null
? writeMonorepoConfigs(writeContext, resolved, targetDir, tool)
await (context.mode === 'monorepo' && context.tool != null
? writeMonorepoConfigs(writeContext, resolved, targetDir, context.tool)
: writeStandaloneConfig(
writeContext,
resolved.map(r => r.result),
));

await resolveGitignore(tree);
await resolveCi(tree, ciProvider, context);

logChanges(tree.listChanges());

Expand Down