diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 78f710ed6..bb261afb5 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -29,6 +29,7 @@ "@code-pushup/models": "0.116.0", "@code-pushup/utils": "0.116.0", "@inquirer/prompts": "^8.0.0", + "yaml": "^2.5.1", "yargs": "^17.7.2" }, "files": [ diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 654b55736..f54aec268 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -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, @@ -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 none)', + }) .check(parsed => { validatePluginSlugs(bindings, parsed.plugins); return true; diff --git a/packages/create-cli/src/lib/setup/ci.ts b/packages/create-cli/src/lib/setup/ci.ts new file mode 100644 index 000000000..9d1007c70 --- /dev/null +++ b/packages/create-cli/src/lib/setup/ci.ts @@ -0,0 +1,154 @@ +import { select } from '@inquirer/prompts'; +import * as YAML from 'yaml'; +import { getGitDefaultBranch, logger } from '@code-pushup/utils'; +import { + CI_PROVIDERS, + type CiProvider, + type CliArgs, + type ConfigContext, + type 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 = '.gitlab/ci/code-pushup.gitlab-ci.yml'; + +export async function promptCiProvider(cliArgs: CliArgs): Promise { + if (isCiProvider(cliArgs.ci)) { + return cliArgs.ci; + } + if (cliArgs.yes) { + return 'none'; + } + return select({ + message: 'CI/CD integration:', + choices: [ + { name: 'GitHub Actions', value: 'github' }, + { name: 'GitLab CI/CD', value: 'gitlab' }, + { name: 'none', value: 'none' }, + ], + default: 'none', + }); +} + +export async function resolveCi( + tree: Tree, + provider: CiProvider, + context: ConfigContext, +): Promise { + switch (provider) { + case 'github': + await writeGitHubWorkflow(tree, context); + break; + case 'gitlab': + await writeGitLabConfig(tree); + break; + case 'none': + break; + } +} + +async function writeGitHubWorkflow( + tree: Tree, + context: ConfigContext, +): Promise { + await tree.write(GITHUB_WORKFLOW_PATH, await generateGitHubYaml(context)); +} + +async function generateGitHubYaml({ + mode, + tool, +}: ConfigContext): Promise { + const branch = await getGitDefaultBranch(); + const lines = [ + 'name: Code PushUp', + '', + 'on:', + ' push:', + ` branches: [${branch}]`, + ' pull_request:', + ` branches: [${branch}]`, + '', + 'permissions:', + ' contents: read', + ' actions: read', + ' pull-requests: write', + '', + 'jobs:', + ' code-pushup:', + ' runs-on: ubuntu-latest', + ' name: Code PushUp', + ' 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', + ...(mode === 'monorepo' && tool != null + ? [' with:', ` monorepo: ${tool}`] + : []), + ]; + return `${lines.join('\n')}\n`; +} + +async function writeGitLabConfig(tree: Tree): Promise { + const filePath = await resolveGitLabFilePath(tree); + await tree.write(filePath, generateGitLabYaml()); + + if (filePath === GITLAB_CONFIG_SEPARATE_PATH) { + await patchRootGitLabConfig(tree); + } +} + +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 patchRootGitLabConfig(tree: Tree): Promise { + const content = await tree.read(GITLAB_CONFIG_PATH); + if (content == null) { + return; + } + const doc = YAML.parseDocument(content); + if (!YAML.isMap(doc.contents)) { + logger.warn( + `Could not update ${GITLAB_CONFIG_PATH}. Add an include entry for ${GITLAB_CONFIG_SEPARATE_PATH} to your config.`, + ); + return; + } + const entry = { local: GITLAB_CONFIG_SEPARATE_PATH }; + const include = doc.get('include', true); + if (include == null) { + doc.set('include', doc.createNode([entry])); + } else if (YAML.isSeq(include)) { + include.add(doc.createNode(entry)); + } else { + const existing = doc.get('include'); + doc.set('include', doc.createNode([existing, entry])); + } + await tree.write(GITLAB_CONFIG_PATH, doc.toString()); +} + +async function resolveGitLabFilePath(tree: Tree): Promise { + if (await tree.exists(GITLAB_CONFIG_PATH)) { + return GITLAB_CONFIG_SEPARATE_PATH; + } + return GITLAB_CONFIG_PATH; +} + +function isCiProvider(value: string | undefined): value is CiProvider { + const validValues: readonly string[] = CI_PROVIDERS; + return value != null && validValues.includes(value); +} diff --git a/packages/create-cli/src/lib/setup/ci.unit.test.ts b/packages/create-cli/src/lib/setup/ci.unit.test.ts new file mode 100644 index 000000000..8059c3e7f --- /dev/null +++ b/packages/create-cli/src/lib/setup/ci.unit.test.ts @@ -0,0 +1,220 @@ +import { select } from '@inquirer/prompts'; +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-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(), +})); + +vi.mock('@code-pushup/utils', async importOriginal => ({ + ...(await importOriginal()), + getGitDefaultBranch: vi.fn().mockResolvedValue('main'), +})); + +describe('promptCiProvider', () => { + it.each(['github', 'gitlab', 'none'] 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 "none" when --yes is provided', async () => { + await expect(promptCiProvider({ yes: true })).resolves.toBe('none'); + 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: 'none', + }), + ); + }); +}); + +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 + name: Code PushUp + 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 + name: Code PushUp + 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', + }); + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "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 + " + `); + }); + + it('should append local include when .gitlab-ci.yml has include array', async () => { + vol.fromJSON( + { + 'package.json': '{}', + '.gitlab-ci.yml': 'include:\n - local: .gitlab/ci/version.yml\n', + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "include: + - local: .gitlab/ci/version.yml + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); + }); + + it('should wrap single include object into array and append', async () => { + vol.fromJSON( + { + 'package.json': '{}', + '.gitlab-ci.yml': 'include:\n local: .gitlab/ci/version.yml\n', + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "include: + - local: .gitlab/ci/version.yml + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); + }); + + it('should create include array when .gitlab-ci.yml has no include key', 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); + + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "stages: + - test + include: + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); + }); + }); + + describe('none', () => { + it('should make no changes when provider is none', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'none', STANDALONE_CONTEXT); + + expect(tree.listChanges()).toStrictEqual([]); + }); + }); +}); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 62dfb028b..0124063fe 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,6 +1,9 @@ import type { PluginMeta } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; +export const CI_PROVIDERS = ['github', 'gitlab', 'none'] 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]; @@ -16,6 +19,7 @@ export type CliArgs = { 'config-format'?: string; mode?: SetupMode; plugins?: string[]; + ci?: string; 'target-dir'?: string; [key: string]: unknown; }; diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 64350061e..cde4c6972 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -7,6 +7,7 @@ import { logger, toUnixPath, } from '@code-pushup/utils'; +import { promptCiProvider, resolveCi } from './ci.js'; import { computeRelativePresetImport, generateConfigSource, @@ -48,39 +49,39 @@ export async function runSetupWizard( ): Promise { 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()); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d32d747c4..62c8401f8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -79,6 +79,7 @@ export { } from './lib/git/git.commits-and-tags.js'; export { formatGitPath, + getGitDefaultBranch, getGitRoot, guardAgainstLocalChanges, safeCheckout, diff --git a/packages/utils/src/lib/git/git.ts b/packages/utils/src/lib/git/git.ts index 86da81c3f..517628477 100644 --- a/packages/utils/src/lib/git/git.ts +++ b/packages/utils/src/lib/git/git.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { type StatusResult, simpleGit } from 'simple-git'; +import { stringifyError } from '../errors.js'; import { logger } from '../logger.js'; import { toUnixPath } from '../transform.js'; @@ -7,6 +8,18 @@ export function getGitRoot(git = simpleGit()): Promise { return git.revparse('--show-toplevel'); } +export async function getGitDefaultBranch(git = simpleGit()): Promise { + try { + const head = await git.revparse('--abbrev-ref origin/HEAD'); + return head.replace(/^origin\//, ''); + } catch (error) { + logger.warn( + `Failed to get the default Git branch, falling back to main - ${stringifyError(error)}`, + ); + return 'main'; + } +} + export function formatGitPath(filePath: string, gitRoot: string): string { const absolutePath = path.isAbsolute(filePath) ? filePath