Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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