From ba1149a3d2560368f877da0035bd5935c2cf53da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 22 Aug 2025 15:43:17 +0200 Subject: [PATCH 1/4] feat(models): export default persist config --- packages/models/src/index.ts | 13 +++++++------ packages/models/src/lib/implementation/constants.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 0b2510395..2811d0e7a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -17,11 +17,11 @@ export { } from './lib/audit-output.js'; export { auditSchema, type Audit } from './lib/audit.js'; export { + cacheConfigObjectSchema, cacheConfigSchema, + cacheConfigShorthandSchema, type CacheConfig, - cacheConfigObjectSchema, type CacheConfigObject, - cacheConfigShorthandSchema, type CacheConfigShorthand, } from './lib/cache-config.js'; export { @@ -31,6 +31,10 @@ export { type CategoryRef, } from './lib/category-config.js'; export { commitSchema, type Commit } from './lib/commit.js'; +export { + artifactGenerationCommandSchema, + pluginArtifactOptionsSchema, +} from './lib/configuration.js'; export { coreConfigSchema, type CoreConfig } from './lib/core-config.js'; export { groupRefSchema, @@ -44,6 +48,7 @@ export { SUPPORTED_CONFIG_FILE_FORMATS, } from './lib/implementation/configuration.js'; export { + DEFAULT_PERSIST_CONFIG, DEFAULT_PERSIST_FILENAME, DEFAULT_PERSIST_FORMAT, DEFAULT_PERSIST_OUTPUT_DIR, @@ -143,7 +148,3 @@ export { type Tree, } from './lib/tree.js'; export { uploadConfigSchema, type UploadConfig } from './lib/upload-config.js'; -export { - artifactGenerationCommandSchema, - pluginArtifactOptionsSchema, -} from './lib/configuration.js'; diff --git a/packages/models/src/lib/implementation/constants.ts b/packages/models/src/lib/implementation/constants.ts index 6cdc05505..a47b3fa9c 100644 --- a/packages/models/src/lib/implementation/constants.ts +++ b/packages/models/src/lib/implementation/constants.ts @@ -1,5 +1,12 @@ -import type { Format } from '../persist-config.js'; +import type { Format, PersistConfig } from '../persist-config.js'; export const DEFAULT_PERSIST_OUTPUT_DIR = '.code-pushup'; export const DEFAULT_PERSIST_FILENAME = 'report'; export const DEFAULT_PERSIST_FORMAT: Format[] = ['json', 'md']; + +export const DEFAULT_PERSIST_CONFIG: Required = { + outputDir: DEFAULT_PERSIST_OUTPUT_DIR, + filename: DEFAULT_PERSIST_FILENAME, + format: DEFAULT_PERSIST_FORMAT, + skipReports: false, +}; From 5f88d59179c0ceaa9b3d8a1f189a1c94b250af61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 22 Aug 2025 17:40:39 +0200 Subject: [PATCH 2/4] feat(ci): add helper function for parsing configPatterns from json string --- packages/ci/src/index.ts | 4 +- packages/ci/src/lib/constants.ts | 22 ---- .../lib/monorepo/list-projects.unit.test.ts | 2 +- packages/ci/src/lib/run-utils.ts | 10 +- packages/ci/src/lib/schemas.ts | 41 +++++++ packages/ci/src/lib/schemas.unit.test.ts | 108 ++++++++++++++++++ packages/ci/src/lib/settings.ts | 49 ++++++++ packages/ci/src/lib/settings.unit.test.ts | 101 ++++++++++++++++ packages/models/src/index.ts | 2 + 9 files changed, 310 insertions(+), 29 deletions(-) delete mode 100644 packages/ci/src/lib/constants.ts create mode 100644 packages/ci/src/lib/schemas.ts create mode 100644 packages/ci/src/lib/schemas.unit.test.ts create mode 100644 packages/ci/src/lib/settings.ts create mode 100644 packages/ci/src/lib/settings.unit.test.ts diff --git a/packages/ci/src/index.ts b/packages/ci/src/index.ts index 5b0b5958e..a8a8e21c4 100644 --- a/packages/ci/src/index.ts +++ b/packages/ci/src/index.ts @@ -1,8 +1,10 @@ export type { SourceFileIssue } from './lib/issues.js'; export type * from './lib/models.js'; export { - MONOREPO_TOOLS, isMonorepoTool, + MONOREPO_TOOLS, type MonorepoTool, } from './lib/monorepo/index.js'; export { runInCI } from './lib/run.js'; +export { configPatternsSchema } from './lib/schemas.js'; +export { parseConfigPatternsFromString } from './lib/settings.js'; diff --git a/packages/ci/src/lib/constants.ts b/packages/ci/src/lib/constants.ts deleted file mode 100644 index 5496f837e..000000000 --- a/packages/ci/src/lib/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Settings } from './models.js'; - -export const DEFAULT_SETTINGS: Settings = { - monorepo: false, - parallel: false, - projects: null, - task: 'code-pushup', - bin: 'npx --no-install code-pushup', - config: null, - directory: process.cwd(), - silent: false, - debug: false, - detectNewIssues: true, - logger: console, - nxProjectsFilter: '--with-target={task}', - skipComment: false, - configPatterns: null, - searchCommits: false, -}; - -export const MIN_SEARCH_COMMITS = 1; -export const MAX_SEARCH_COMMITS = 100; diff --git a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts index 63f6a5b70..5476737fe 100644 --- a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts +++ b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import type { PackageJson } from 'type-fest'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; -import { DEFAULT_SETTINGS } from '../constants.js'; import type { Settings } from '../models.js'; +import { DEFAULT_SETTINGS } from '../settings.js'; import { type MonorepoProjects, listMonorepoProjects, diff --git a/packages/ci/src/lib/run-utils.ts b/packages/ci/src/lib/run-utils.ts index 29db1dcbf..c5216045c 100644 --- a/packages/ci/src/lib/run-utils.ts +++ b/packages/ci/src/lib/run-utils.ts @@ -28,11 +28,6 @@ import { runCompare, runPrintConfig, } from './cli/index.js'; -import { - DEFAULT_SETTINGS, - MAX_SEARCH_COMMITS, - MIN_SEARCH_COMMITS, -} from './constants.js'; import { listChangedFiles, normalizeGitRef } from './git.js'; import { type SourceFileIssue, filterRelevantIssues } from './issues.js'; import type { @@ -49,6 +44,11 @@ import type { import type { ProjectConfig } from './monorepo/index.js'; import { saveOutputFiles } from './output-files.js'; import { downloadFromPortal } from './portal/download.js'; +import { + DEFAULT_SETTINGS, + MAX_SEARCH_COMMITS, + MIN_SEARCH_COMMITS, +} from './settings.js'; export type RunEnv = { refs: NormalizedGitRefs; diff --git a/packages/ci/src/lib/schemas.ts b/packages/ci/src/lib/schemas.ts new file mode 100644 index 000000000..21424d506 --- /dev/null +++ b/packages/ci/src/lib/schemas.ts @@ -0,0 +1,41 @@ +import { ZodError, z } from 'zod'; +import { + DEFAULT_PERSIST_CONFIG, + persistConfigSchema, + slugSchema, + uploadConfigSchema, +} from '@code-pushup/models'; +import { interpolate } from '@code-pushup/utils'; + +// eslint-disable-next-line unicorn/prefer-top-level-await, unicorn/catch-error-name +export const interpolatedSlugSchema = slugSchema.catch(ctx => { + // allow {projectName} interpolation (invalid slug) + if ( + typeof ctx.value === 'string' && + ctx.issues.length === 1 && + ctx.issues[0]?.code === 'invalid_format' + ) { + // if only regex failed, try if it would pass once we insert known variables + const { success } = slugSchema.safeParse( + interpolate(ctx.value, { projectName: 'example' }), + ); + if (success) { + return ctx.value; + } + } + throw new ZodError(ctx.error.issues); +}); + +export const configPatternsSchema = z.object({ + persist: persistConfigSchema.transform(persist => ({ + ...DEFAULT_PERSIST_CONFIG, + ...persist, + })), + upload: uploadConfigSchema + .omit({ organization: true, project: true }) + .extend({ + organization: interpolatedSlugSchema, + project: interpolatedSlugSchema, + }) + .optional(), +}); diff --git a/packages/ci/src/lib/schemas.unit.test.ts b/packages/ci/src/lib/schemas.unit.test.ts new file mode 100644 index 000000000..f11074076 --- /dev/null +++ b/packages/ci/src/lib/schemas.unit.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { ZodError } from 'zod'; +import type { ConfigPatterns } from './models.js'; +import { configPatternsSchema, interpolatedSlugSchema } from './schemas.js'; + +describe('interpolatedSlugSchema', () => { + it('should accept a valid slug', () => { + expect(interpolatedSlugSchema.parse('valid-slug')).toBe('valid-slug'); + }); + + it('should accept a slug with {projectName} interpolation', () => { + expect(interpolatedSlugSchema.parse('{projectName}-slug')).toBe( + '{projectName}-slug', + ); + }); + + it('should reject an invalid slug that cannot be fixed by interpolation', () => { + expect(() => interpolatedSlugSchema.parse('Invalid Slug!')).toThrow( + ZodError, + ); + }); + + it('should reject a non-string value', () => { + expect(() => interpolatedSlugSchema.parse(123)).toThrow(ZodError); + }); +}); + +describe('configPatternsSchema', () => { + it('should accept valid persist and upload configs', () => { + const configPatterns: Required = { + persist: { + outputDir: '.code-pushup/{projectName}', + filename: 'report', + format: ['json', 'md'], + skipReports: false, + }, + upload: { + server: 'https://api.code-pushup.example.com/graphql', + apiKey: 'cp_...', + organization: 'example', + project: '{projectName}', + }, + }; + expect(configPatternsSchema.parse(configPatterns)).toEqual(configPatterns); + }); + + it('should accept persist config without upload', () => { + const configPatterns: ConfigPatterns = { + persist: { + outputDir: '.code-pushup/{projectName}', + filename: 'report', + format: ['json', 'md'], + skipReports: false, + }, + }; + expect(configPatternsSchema.parse(configPatterns)).toEqual(configPatterns); + }); + + it('fills in default persist values if missing', () => { + expect( + configPatternsSchema.parse({ + persist: { + filename: '{projectName}-report', + }, + }), + ).toEqual({ + persist: { + outputDir: '.code-pushup', + filename: '{projectName}-report', + format: ['json', 'md'], + skipReports: false, + }, + }); + }); + + it('should reject if persist is missing', () => { + expect(() => configPatternsSchema.parse({})).toThrow(ZodError); + }); + + it('should reject if persist has invalid values', () => { + expect(() => + configPatternsSchema.parse({ + persist: { + format: 'json', // should be array + }, + }), + ).toThrow(ZodError); + }); + + it('should reject if upload is missing required fields', () => { + expect(() => + configPatternsSchema.parse({ + persist: { + outputDir: '.code-pushup/{projectName}', + filename: 'report', + format: ['json', 'md'], + skipReports: false, + }, + upload: { + server: 'https://api.code-pushup.example.com/graphql', + organization: 'example', + project: '{projectName}', + // missing apiKey + }, + }), + ).toThrow(ZodError); + }); +}); diff --git a/packages/ci/src/lib/settings.ts b/packages/ci/src/lib/settings.ts new file mode 100644 index 000000000..f942fc891 --- /dev/null +++ b/packages/ci/src/lib/settings.ts @@ -0,0 +1,49 @@ +import { ZodError, z } from 'zod'; +import type { ConfigPatterns, Settings } from './models.js'; +import { configPatternsSchema } from './schemas.js'; + +export const DEFAULT_SETTINGS: Settings = { + monorepo: false, + parallel: false, + projects: null, + task: 'code-pushup', + bin: 'npx --no-install code-pushup', + config: null, + directory: process.cwd(), + silent: false, + debug: false, + detectNewIssues: true, + logger: console, + nxProjectsFilter: '--with-target={task}', + skipComment: false, + configPatterns: null, + searchCommits: false, +}; + +export const MIN_SEARCH_COMMITS = 1; +export const MAX_SEARCH_COMMITS = 100; + +export function parseConfigPatternsFromString( + value: string, +): ConfigPatterns | null { + if (!value) { + return null; + } + + try { + const json = JSON.parse(value); + return configPatternsSchema.parse(json); + } catch (error) { + if (error instanceof SyntaxError) { + throw new TypeError( + `Invalid JSON value for configPatterns input - ${error.message}`, + ); + } + if (error instanceof ZodError) { + throw new TypeError( + `Invalid shape of configPatterns input:\n${z.prettifyError(error)}`, + ); + } + throw error; + } +} diff --git a/packages/ci/src/lib/settings.unit.test.ts b/packages/ci/src/lib/settings.unit.test.ts new file mode 100644 index 000000000..7bd4a5444 --- /dev/null +++ b/packages/ci/src/lib/settings.unit.test.ts @@ -0,0 +1,101 @@ +import type { CoreConfig } from '@code-pushup/models'; +import type { ConfigPatterns } from './models.js'; +import { parseConfigPatternsFromString } from './settings.js'; + +describe('parseConfigPatternsFromString', () => { + it('should return for empty string', () => { + expect(parseConfigPatternsFromString('')).toBeNull(); + }); + + it('should parse full persist and upload configs', () => { + const configPatterns: Required = { + persist: { + outputDir: '.code-pushup/{projectName}', + filename: 'report', + format: ['json', 'md'], + skipReports: false, + }, + upload: { + server: 'https://api.code-pushup.example.com/graphql', + apiKey: 'cp_...', + organization: 'example', + project: '{projectName}', + }, + }; + expect( + parseConfigPatternsFromString(JSON.stringify(configPatterns)), + ).toEqual(configPatterns); + }); + + it('should parse full persist config without upload config', () => { + const configPatterns: ConfigPatterns = { + persist: { + outputDir: '.code-pushup/{projectName}', + filename: 'report', + format: ['json', 'md'], + skipReports: false, + }, + }; + expect( + parseConfigPatternsFromString(JSON.stringify(configPatterns)), + ).toEqual(configPatterns); + }); + + it('should fill in default persist values where missing', () => { + expect( + parseConfigPatternsFromString( + JSON.stringify({ + persist: { + filename: '{projectName}-report', + }, + } satisfies Pick), + ), + ).toEqual({ + persist: { + outputDir: '.code-pushup', + filename: '{projectName}-report', + format: ['json', 'md'], + skipReports: false, + }, + }); + }); + + it('should throw if input string is not valid JSON', () => { + expect(() => + parseConfigPatternsFromString('outputDir: .code-pushup/{projectName}'), + ).toThrow('Invalid JSON value for configPatterns input - Unexpected token'); + }); + + it('should throw if persist config is missing', () => { + expect(() => parseConfigPatternsFromString('{}')).toThrow( + /Invalid shape of configPatterns input.*expected object, received undefined.*at persist/s, + ); + }); + + it('should throw if persist config has invalid values', () => { + expect(() => + parseConfigPatternsFromString( + JSON.stringify({ persist: { format: 'json' } }), + ), + ).toThrow( + /Invalid shape of configPatterns input.*expected array, received string.*at persist\.format/s, + ); + }); + + it('should throw if upload config has missing values', () => { + expect(() => + parseConfigPatternsFromString( + JSON.stringify({ + persist: {}, + upload: { + server: 'https://api.code-pushup.example.com/graphql', + organization: 'example', + project: '{projectName}', + }, + }), + ), + ).toThrow( + /Invalid shape of configPatterns input.*expected string, received undefined.*at upload\.apiKey/s, + ); + }); +}); diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 2811d0e7a..0a0d2c71d 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -63,6 +63,8 @@ export { fileNameSchema, filePathSchema, materialIconSchema, + scoreSchema, + slugSchema, type MaterialIcon, } from './lib/implementation/schemas.js'; export { exists } from './lib/implementation/utils.js'; From 6d8a69e1a0642b43ce0aeb4403c52562b24b209c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 22 Aug 2025 17:51:17 +0200 Subject: [PATCH 3/4] feat(ci): export default settings and min/max limits --- packages/ci/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ci/src/index.ts b/packages/ci/src/index.ts index a8a8e21c4..3e67b1b3e 100644 --- a/packages/ci/src/index.ts +++ b/packages/ci/src/index.ts @@ -7,4 +7,9 @@ export { } from './lib/monorepo/index.js'; export { runInCI } from './lib/run.js'; export { configPatternsSchema } from './lib/schemas.js'; -export { parseConfigPatternsFromString } from './lib/settings.js'; +export { + DEFAULT_SETTINGS, + MAX_SEARCH_COMMITS, + MIN_SEARCH_COMMITS, + parseConfigPatternsFromString, +} from './lib/settings.js'; From 7e906d61a5aaee400306418d63870c6f89c76df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= <34691111+matejchalk@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:06:36 +0200 Subject: [PATCH 4/4] feat(models): export default persist.skipReports value Co-authored-by: Michael Hladky <10064416+BioPhoton@users.noreply.github.com> --- packages/models/src/lib/implementation/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/models/src/lib/implementation/constants.ts b/packages/models/src/lib/implementation/constants.ts index a47b3fa9c..f522c5b89 100644 --- a/packages/models/src/lib/implementation/constants.ts +++ b/packages/models/src/lib/implementation/constants.ts @@ -3,10 +3,11 @@ import type { Format, PersistConfig } from '../persist-config.js'; export const DEFAULT_PERSIST_OUTPUT_DIR = '.code-pushup'; export const DEFAULT_PERSIST_FILENAME = 'report'; export const DEFAULT_PERSIST_FORMAT: Format[] = ['json', 'md']; +export const DEFAULT_PERSIST_SKIP_REPORT = false; export const DEFAULT_PERSIST_CONFIG: Required = { outputDir: DEFAULT_PERSIST_OUTPUT_DIR, filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, - skipReports: false, + skipReports: DEFAULT_PERSIST_SKIP_REPORT, };