Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
9 changes: 8 additions & 1 deletion packages/ci/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
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 {
DEFAULT_SETTINGS,
MAX_SEARCH_COMMITS,
MIN_SEARCH_COMMITS,
parseConfigPatternsFromString,
} from './lib/settings.js';
22 changes: 0 additions & 22 deletions packages/ci/src/lib/constants.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/ci/src/lib/monorepo/list-projects.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions packages/ci/src/lib/run-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions packages/ci/src/lib/schemas.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
108 changes: 108 additions & 0 deletions packages/ci/src/lib/schemas.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigPatterns> = {
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<ConfigPatterns>({
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);
});
});
49 changes: 49 additions & 0 deletions packages/ci/src/lib/settings.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
101 changes: 101 additions & 0 deletions packages/ci/src/lib/settings.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigPatterns> = {
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<CoreConfig, 'persist'>),
),
).toEqual<ConfigPatterns>({
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,
);
});
});
Loading
Loading