diff --git a/.changeset/custom-yaml-jobs.md b/.changeset/custom-yaml-jobs.md new file mode 100644 index 0000000..449bf70 --- /dev/null +++ b/.changeset/custom-yaml-jobs.md @@ -0,0 +1,5 @@ +--- +'@pull-up/cli': patch +--- + +Add `type: custom` jobs to YAML configuration. Commands receive source files and execution context as JSON on stdin and write generated file contents to stdout. diff --git a/.changeset/deprecate-script-configs.md b/.changeset/deprecate-script-configs.md new file mode 100644 index 0000000..8ec6883 --- /dev/null +++ b/.changeset/deprecate-script-configs.md @@ -0,0 +1,5 @@ +--- +'@pull-up/cli': patch +--- + +Warn when loading JavaScript or TypeScript configuration files while preserving their behavior. Add migration examples for built-in and custom jobs using YAML configuration. diff --git a/README.md b/README.md index 7cedf11..5db4e42 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,49 @@ jobs: Input patterns and the output path are relative to the repository root. Prefix an input pattern with `!` to exclude matching files. The output file is automatically excluded from the input. +### Custom Jobs + +Use `type: custom` to generate a file with a shell command: + +```yaml +jobs: + ownership: + type: custom + command: 'python3 scripts/ownership.py' + input: ['packages/**/ownership.yaml'] + output: .github/CODEOWNERS +``` + +| Field | Type | Required | Default | +| --------- | ---------- | -------- | ------- | +| `type` | `custom` | Yes | | +| `command` | `string` | Yes | | +| `input` | `string[]` | No | `[]` | +| `output` | `string` | Yes | | + +The command runs through `sh -c` from the root, so shell syntax such as pipes is supported. It receives a single JSON object on stdin: + +```json +{ + "sources": [ + { + "path": "packages/core/ownership.yaml", + "contents": "owners: ['@core-team']\n" + } + ], + "context": { + "rootDir": "/project", + "outputPath": "/project/.github/CODEOWNERS" + } +} +``` + +Source paths are relative to the repository root, and their contents are UTF-8 text. Both context paths are absolute. + +Write the generated file contents to stdout and exit with code `0`. Trailing newlines are preserved. Use stderr for error messages and exit with a non-zero code on failure. + +The command runs on every `pullup sync`, `pullup check`, and `pullup sync --dry-run`. pull-up writes the generated file only during `sync` without `--dry-run`. + ### Editor Support The package includes `schema.json` for autocomplete and validation in editors that support YAML Language Server. In VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add this line at the top of your config: @@ -91,6 +134,10 @@ The path is relative to the YAML file. For Yarn Plug'n'Play or a remote schema, ### JavaScript and TypeScript Configuration +Starting in 0.0.8, JavaScript and TypeScript configuration files are deprecated. They continue to work, but pull-up prints one warning to stderr per command when it loads a `.js`, `.ts`, `.cjs`, or `.mjs` configuration file, including `.pulluprc` files with these extensions. See [Migration](#migration) to move to YAML. + +The `package.json` `pullup` field and JSON/YAML `.pulluprc` configurations remain supported without this deprecation warning. + Use `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, or `pullup.config.mjs` for a JavaScript or TypeScript configuration: ```ts @@ -103,7 +150,7 @@ The `package.json` `pullup` field and `.pulluprc` files are also supported. Thes #### Custom Jobs -YAML currently supports built-in jobs only. To define a custom job, use `defineJob` in a JavaScript or TypeScript config: +Use `defineJob` to implement a custom transform in a JavaScript or TypeScript config: ```ts import { defineConfig, defineJob } from '@pull-up/cli'; @@ -122,6 +169,81 @@ export default defineConfig([myJob()]); Transform functions receive source files as `{ path, contents }` objects and a context containing `rootDir` and `outputPath`. They can return a string or a promise of a string. +### Migration + +Replace a built-in job in `pullup.config.mjs`: + +```js +import { defineConfig, codeownersJob } from '@pull-up/cli'; + +export default defineConfig( + codeownersJob({ + input: ['**/CODEOWNERS', '!**/fixtures/**'], + output: '.github/CODEOWNERS', + }), +); +``` + +With `pullup.yml`: + +```yaml +jobs: + codeowners: + type: codeowners + input: ['**/CODEOWNERS', '!**/fixtures/**'] + output: .github/CODEOWNERS +``` + +For a custom callback, move the transform into a command. For example, replace this `pullup.config.mjs`: + +```js +import { defineConfig, defineJob } from '@pull-up/cli'; + +const mergeConfigs = defineJob({ + name: 'merge-configs', + input: ['packages/*/config.json'], + output: 'merged-config.json', + transform: (sources) => `${JSON.stringify(sources.map((source) => JSON.parse(source.contents)))}\n`, +}); + +export default defineConfig([mergeConfigs()]); +``` + +With `pullup.yml`: + +```yaml +jobs: + merge-configs: + type: custom + command: 'node scripts/merge-configs.mjs' + input: ['packages/*/config.json'] + output: merged-config.json +``` + +Create `scripts/merge-configs.mjs`: + +```js +process.stdin.setEncoding('utf8'); +let input = ''; +for await (const chunk of process.stdin) { + input += chunk; +} + +const { sources, context } = JSON.parse(input); + +try { + const configs = sources.map((source) => JSON.parse(source.contents)); + process.stdout.write(`${JSON.stringify(configs)}\n`); +} catch (error) { + console.error(`Failed to generate ${context.outputPath}: ${error.message}`); + process.exitCode = 1; +} +``` + +The script reads sources and context from JSON on stdin and writes the generated contents, including the final newline, to stdout. It does not write the output file itself. Input patterns and output paths remain relative to the repository root; the command also runs from that root. Your environment must provide the runtime used by the command, such as Node.js in this example. + +After moving your jobs, delete the old JavaScript or TypeScript configuration file. Legacy configurations take precedence over `pullup.yaml` and `pullup.yml`, so leaving the old file in place prevents pull-up from loading your new YAML configuration. Run `pullup sync --dry-run` to preview the migrated jobs, then use `pullup sync` and `pullup check` as usual. + ## Usage ### Sync diff --git a/fixtures/custom-job/ownership/codeowners.yaml b/fixtures/custom-job/ownership/codeowners.yaml new file mode 100644 index 0000000..45bdb8b --- /dev/null +++ b/fixtures/custom-job/ownership/codeowners.yaml @@ -0,0 +1,12 @@ +people: + alice: + github: alice-dev + bob: + github: bob-dev + carol: + github: carol-dev +teams: + platform: + members: + - bob + - alice diff --git a/fixtures/custom-job/ownership/generate-codeowners.mjs b/fixtures/custom-job/ownership/generate-codeowners.mjs new file mode 100644 index 0000000..05a83e8 --- /dev/null +++ b/fixtures/custom-job/ownership/generate-codeowners.mjs @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { defaultLoaders } from 'cosmiconfig'; + +const { sources } = JSON.parse(readFileSync(0, 'utf8')); +const configs = sources.map(({ path: sourcePath, contents }) => ({ + path: sourcePath, + config: defaultLoaders['.yaml'](sourcePath, contents), +})); +const registry = configs.find(({ path: sourcePath }) => sourcePath === 'codeowners.yaml').config; +const declarations = configs + .filter(({ path: sourcePath }) => sourcePath !== 'codeowners.yaml') + .flatMap(({ path: sourcePath, config }) => { + const directory = path.posix.dirname(sourcePath); + return [ + { directory, owners: config.owners }, + ...(config.rules ?? []).map((rule) => ({ + directory: path.posix.join(directory, rule.path).replace(/\/$/, ''), + owners: rule.owners, + })), + ]; + }); + +declarations.sort((left, right) => { + const depth = left.directory.split('/').length - right.directory.split('/').length; + return depth || left.directory.localeCompare(right.directory); +}); + +const lines = declarations.map(({ directory, owners }) => { + const members = new Set([ + ...(owners.teams ?? []).flatMap((team) => registry.teams[team].members), + ...(owners.people ?? []), + ]); + const handles = Object.entries(registry.people) + .filter(([person]) => members.has(person)) + .map(([, person]) => `@${person.github}`) + .join(' '); + return `/${directory}/ ${handles}\n`; +}); + +process.stdout.write(lines.join('')); diff --git a/fixtures/custom-job/ownership/packages/core/child/ownership.yaml b/fixtures/custom-job/ownership/packages/core/child/ownership.yaml new file mode 100644 index 0000000..fd15289 --- /dev/null +++ b/fixtures/custom-job/ownership/packages/core/child/ownership.yaml @@ -0,0 +1,3 @@ +owners: + people: + - bob diff --git a/fixtures/custom-job/ownership/packages/core/ownership.yaml b/fixtures/custom-job/ownership/packages/core/ownership.yaml new file mode 100644 index 0000000..d589741 --- /dev/null +++ b/fixtures/custom-job/ownership/packages/core/ownership.yaml @@ -0,0 +1,14 @@ +owners: + teams: + - platform + people: + - alice +rules: + - path: feature/details/ + owners: + people: + - bob + - path: feature/ + owners: + people: + - carol diff --git a/fixtures/custom-job/ownership/packages/web/ownership.yaml b/fixtures/custom-job/ownership/packages/web/ownership.yaml new file mode 100644 index 0000000..0af9581 --- /dev/null +++ b/fixtures/custom-job/ownership/packages/web/ownership.yaml @@ -0,0 +1,3 @@ +owners: + people: + - carol diff --git a/package.json b/package.json index 2fda3f3..264bd75 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "dependencies": { "clipanion": "^4.0.0-rc.4", "cosmiconfig": "^9.0.0", + "execa": "^10.0.1", "fast-glob": "^3.3.3", "find-up": "^8.0.0", "valibot": "^1.5.0" diff --git a/schema.json b/schema.json index 51c659a..ad2e88d 100644 --- a/schema.json +++ b/schema.json @@ -14,38 +14,88 @@ } }, "additionalProperties": { - "type": "object", - "properties": { - "type": { - "const": "codeowners" - }, - "input": { - "type": "array", - "items": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "codeowners" + }, + "input": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Input file paths for the job", + "examples": [ + [ + "**/CODEOWNERS", + "!**/fixtures/**" + ] + ] + }, + "output": { + "type": "string", + "description": "Output file path for the job", + "examples": [ + "CODEOWNERS", + ".github/CODEOWNERS", + "docs/CODEOWNERS" + ] + } }, - "description": "Input file paths for the job", - "examples": [ - [ - "**/CODEOWNERS", - "!**/fixtures/**" - ] - ] + "required": [ + "type" + ], + "additionalProperties": false }, - "output": { - "type": "string", - "description": "Output file path for the job", - "examples": [ - "CODEOWNERS", - ".github/CODEOWNERS", - "docs/CODEOWNERS" - ] + { + "type": "object", + "properties": { + "type": { + "const": "custom" + }, + "command": { + "type": "string", + "minLength": 1, + "description": "The command to run for the custom job", + "examples": [ + "node scripts/generate-codeowners.js", + "python scripts/generate-codeowners.py" + ] + }, + "input": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Input file paths for the job", + "examples": [ + [ + "**/CODEOWNERS", + "!**/fixtures/**" + ] + ] + }, + "output": { + "type": "string", + "minLength": 1, + "description": "Output file path for the job", + "examples": [ + "CODEOWNERS", + ".github/CODEOWNERS", + "docs/CODEOWNERS" + ] + } + }, + "required": [ + "type", + "command", + "output" + ], + "additionalProperties": false } - }, - "required": [ - "type" - ], - "additionalProperties": false + ] } } }, diff --git a/src/core/config/resolve-config.spec.ts b/src/core/config/resolve-config.spec.ts index 719e6d7..6dbace2 100644 --- a/src/core/config/resolve-config.spec.ts +++ b/src/core/config/resolve-config.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { Fixture } from '@fixture-kit/core'; import { resolveConfig } from './resolve-config'; @@ -18,6 +20,10 @@ const defaultYamlConfig = `jobs: `; describe('resolveConfig', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -33,6 +39,7 @@ describe('resolveConfig', () => { transform: expect.any(Function), }, ]); + expect(console.warn).not.toHaveBeenCalled(); }); it('resolves explicit input and output from pullup.yaml', async () => { @@ -83,35 +90,73 @@ describe('resolveConfig', () => { ['.pulluprc.ts', tsConfig], ['.pulluprc.cjs', cjsConfig], ['.pulluprc.mjs', esmConfig], + ])('resolves jobs from %s and warns about deprecation once', async (filename, contents) => { + await using fixture = await Fixture.create({ + 'package.json': JSON.stringify({ type: 'module' }), + [filename]: contents, + }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(fixture.root, filename))); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('[DEPRECATED]')); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('pullup.yml or pullup.yaml')); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('external commands')); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('https://github.com/toss/pull-up#migration')); + }); + + it.each([ ['.pulluprc', rcConfig], ['.pulluprc.json', jsonConfig], ['.pulluprc.yaml', rcConfig], ['.pulluprc.yml', rcConfig], - ])('resolves jobs from %s', async (filename, contents) => { + ])('resolves jobs from %s without a deprecation warning', async (filename, contents) => { await using fixture = await Fixture.create({ 'package.json': JSON.stringify({ type: 'module' }), [filename]: contents, }); expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('warns about an empty JavaScript configuration', async () => { + await using fixture = await Fixture.create({ 'pullup.config.cjs': 'module.exports = [];' }); + + expect(await resolveConfig(fixture.root)).toEqual([]); + expect(console.warn).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(fixture.root, 'pullup.config.cjs'))); + }); + + it('warns about a script configuration discovered inside .config', async () => { + await using fixture = await Fixture.create({ '.config': { 'pulluprc.cjs': cjsConfig } }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining(path.join(fixture.root, '.config', 'pulluprc.cjs')), + ); }); it('resolves jobs from package.json#pullup', async () => { await using fixture = await Fixture.create({ 'package.json': JSON.stringify({ pullup: customJobs }) }); expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).not.toHaveBeenCalled(); }); it('resolves jobs from .config/pulluprc', async () => { await using fixture = await Fixture.create({ '.config': { pulluprc: rcConfig } }); expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).not.toHaveBeenCalled(); }); it('returns no jobs when no config exists', async () => { await using fixture = await Fixture.create({}); expect(await resolveConfig(fixture.root)).toEqual([]); + expect(console.warn).not.toHaveBeenCalled(); }); it('prefers pullup.yaml over pullup.yml', async () => { @@ -129,10 +174,9 @@ describe('resolveConfig', () => { 'pullup.config.js': esmConfig, 'pullup.yml': 'jobs: []\n', }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(await resolveConfig(fixture.root)).toEqual(customJobs); - expect(warn).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path.join(fixture.root, 'pullup.config.js'))); }); it('prefers package.json over pullup.config.js', async () => { @@ -142,6 +186,7 @@ describe('resolveConfig', () => { }); expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(console.warn).not.toHaveBeenCalled(); }); it('uses cosmiconfig.searchPlaces from package.json', async () => { diff --git a/src/core/config/resolve-config.ts b/src/core/config/resolve-config.ts index 574328f..a78f714 100644 --- a/src/core/config/resolve-config.ts +++ b/src/core/config/resolve-config.ts @@ -1,9 +1,11 @@ import path from 'node:path'; +import { styleText } from 'node:util'; import { cosmiconfig, getDefaultSearchPlaces } from 'cosmiconfig'; import * as v from 'valibot'; import { codeownersJob } from '../jobs'; +import { customJob } from '../jobs/custom-job'; import type { Job } from '../types'; import { type Config, ConfigSchema } from './schema'; @@ -21,6 +23,20 @@ export async function resolveConfig(cwd: string): Promise { return toJobs(v.parse(ConfigSchema, result.config)); } + if (['.js', '.ts', '.cjs', '.mjs'].includes(path.extname(result.filepath))) { + console.warn( + styleText( + 'yellow', + [ + `[DEPRECATED] JavaScript/TypeScript configuration is deprecated: ${result.filepath}`, + 'Use pullup.yml or pullup.yaml for built-in jobs and external commands for custom transforms.', + 'Migration guide: https://github.com/toss/pull-up#migration', + '', + ].join('\n'), + ), + ); + } + return result.config as Job[]; } @@ -29,8 +45,8 @@ function toJobs(config: Config): Job[] { switch (job.type) { case 'codeowners': return codeownersJob({ input: job.input, output: job.output }); - default: - throw new Error(`Unknown job type: ${job.type}`); + case 'custom': + return customJob({ input: job.input, output: job.output, command: job.command }); } }); } diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index f88d52d..2f928d4 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -17,6 +17,30 @@ const CodeownersJobSchema = v.strictObject({ ), }); +const CustomJobSchema = v.strictObject({ + type: v.literal('custom'), + command: v.pipe( + v.string(), + v.minLength(1), + v.metadata({ + description: 'The command to run for the custom job', + examples: ['node scripts/generate-codeowners.js', 'python scripts/generate-codeowners.py'], + }), + ), + input: v.pipe( + v.optional(v.array(v.string())), + v.metadata({ description: 'Input file paths for the job', examples: [['**/CODEOWNERS', '!**/fixtures/**']] }), + ), + output: v.pipe( + v.string(), + v.nonEmpty(), + v.metadata({ + description: 'Output file path for the job', + examples: ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS'], + }), + ), +}); + export const ConfigSchema = v.strictObject({ jobs: v.pipe( v.custom>( @@ -27,7 +51,7 @@ export const ConfigSchema = v.strictObject({ (jobs) => Object.keys(jobs).every((key) => v.is(JobKeySchema, key)), 'jobs must not use constructor, prototype, or __proto__ as keys.', ), - v.record(JobKeySchema, CodeownersJobSchema), + v.record(JobKeySchema, v.union([CodeownersJobSchema, CustomJobSchema])), ), }); diff --git a/src/core/jobs/custom-job.spec.ts b/src/core/jobs/custom-job.spec.ts new file mode 100644 index 0000000..ba6dd19 --- /dev/null +++ b/src/core/jobs/custom-job.spec.ts @@ -0,0 +1,77 @@ +import { realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Fixture } from '@fixture-kit/core'; + +import { runJob } from '../run-job'; +import { customJob } from './custom-job'; + +const nodeCommand = `'${process.execPath.replaceAll("'", "'\\''")}'`; +const ownershipFixtureDirectory = fileURLToPath(new URL('../../../fixtures/custom-job/ownership/', import.meta.url)); + +describe('customJob', () => { + it('passes sources and context on stdin and preserves stdout', async () => { + await using fixture = await Fixture.create({ + 'transform.mjs': String.raw` +import { readFileSync } from 'node:fs'; + +const request = JSON.parse(readFileSync(0, 'utf8')); +process.stderr.write('diagnostic\n'); +process.stdout.write(JSON.stringify({ request, cwd: process.cwd() }) + '\r\n\n'); +`, + }); + const rootDir = await realpath(fixture.root); + const sources = [{ path: 'packages/core/config.json', contents: '{"name":"코어"}\r\n' }]; + const context = { rootDir, outputPath: path.join(rootDir, 'merged-config.json') }; + const job = customJob({ command: `${nodeCommand} 'transform.mjs'`, output: 'merged-config.json' }); + + const result = await job.transform(sources, context); + + expect(result).toBe(JSON.stringify({ request: { sources, context }, cwd: rootDir }) + '\r\n\n'); + }); + + it('resolves people, teams and folder rules when generating CODEOWNERS', async () => { + await using fixture = await Fixture.fromDirectory(ownershipFixtureDirectory); + const rootDir = await realpath(fixture.root); + const scriptPath = path.join(ownershipFixtureDirectory, 'generate-codeowners.mjs'); + const job = customJob({ + command: `${nodeCommand} '${scriptPath.replaceAll("'", "'\\''")}'`, + input: ['codeowners.yaml', 'packages/**/ownership.yaml'], + output: '.github/CODEOWNERS', + }); + + const result = await runJob(job, rootDir); + + expect(result.generated).toEqual({ + path: path.join(rootDir, '.github/CODEOWNERS'), + contents: + '/packages/core/ @alice-dev @bob-dev\n' + + '/packages/web/ @carol-dev\n' + + '/packages/core/child/ @bob-dev\n' + + '/packages/core/feature/ @carol-dev\n' + + '/packages/core/feature/details/ @bob-dev\n', + }); + }); + + it('rejects with the exit code and stderr when the command fails', async () => { + await using fixture = await Fixture.create({ + 'transform.mjs': String.raw` +import { readFileSync } from 'node:fs'; + +readFileSync(0, 'utf8'); +process.stdout.write('partial output\n'); +process.stderr.write('transform failed\n'); +process.exitCode = 7; +`, + }); + const rootDir = await realpath(fixture.root); + const context = { rootDir, outputPath: path.join(rootDir, 'merged-config.json') }; + const job = customJob({ command: `${nodeCommand} 'transform.mjs'`, output: 'merged-config.json' }); + + await expect(job.transform([], context)).rejects.toMatchObject({ + exitCode: 7, + stderr: 'transform failed\n', + }); + }); +}); diff --git a/src/core/jobs/custom-job.ts b/src/core/jobs/custom-job.ts new file mode 100644 index 0000000..2d1d6cc --- /dev/null +++ b/src/core/jobs/custom-job.ts @@ -0,0 +1,26 @@ +import { execa } from 'execa'; + +import { defineJob } from '../define-job'; + +interface CustomJobOptions { + command: string; + input?: string[]; + output: string; +} + +export const customJob = defineJob((options: CustomJobOptions) => ({ + name: 'custom', + input: options.input ?? [], + output: options.output, + transform: async (inputFiles, context) => { + const { stdout } = await execa('sh', ['-c', options.command], { + cwd: context.rootDir, + input: JSON.stringify({ sources: inputFiles, context }), + stripFinalNewline: false, + maxBuffer: Infinity, + killDescendants: true, + }); + + return stdout; + }, +})); diff --git a/tests/codeowners-cli.spec.ts b/tests/codeowners-cli.spec.ts index 0e577ab..cb1f047 100644 --- a/tests/codeowners-cli.spec.ts +++ b/tests/codeowners-cli.spec.ts @@ -13,6 +13,16 @@ const config = `import { codeownersJob } from ${JSON.stringify(CORE_URL)}; export default [codeownersJob()]; `; +function expectScriptConfigWarning(result: { stdout: string; stderr: string }, configPath: string) { + expect(result.stderr).toBe( + `[DEPRECATED] JavaScript/TypeScript configuration is deprecated: ${configPath}\n` + + 'Use pullup.yml or pullup.yaml for built-in jobs and external commands for custom transforms.\n' + + 'Migration guide: https://github.com/toss/pull-up#migration\n\n', + ); + expect(result.stderr.match(/\[DEPRECATED\]/g)).toHaveLength(1); + expect(result.stdout).not.toContain('[DEPRECATED]'); +} + describe('codeowners-cli', () => { it.each([ { name: 'the repository root', cwd: 'repo', explicitRoot: false }, @@ -45,13 +55,15 @@ describe('codeowners-cli', () => { const options: string[] = []; if (scenario.explicitRoot) options.push('--root', rootDir); if (scenario.explicitCwd === true) options.push('--cwd', subdirectory); + const configDirectory = scenario.explicitCwd === true ? subdirectory : path.join(fixture.root, scenario.cwd); + const configPath = path.join(configDirectory, 'pullup.config.mjs'); const sync = await execFileAsync(process.execPath, [CLI_PATH, 'sync', ...options], { cwd: path.join(fixture.root, scenario.cwd), timeout: 10_000, }); expect(sync.stdout).toContain('codeowners synced'); - expect(sync.stderr).toBe(''); + expectScriptConfigWarning(sync, configPath); expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe( '* @root-team\n' + @@ -73,7 +85,7 @@ describe('codeowners-cli', () => { timeout: 10_000, }); expect(check.stdout).toContain('All files are up to date'); - expect(check.stderr).toBe(''); + expectScriptConfigWarning(check, configPath); if (scenario.explicitRoot || scenario.explicitCwd === true) { await expect(readFile(path.join(otherRoot, '.github/CODEOWNERS'), 'utf8')).rejects.toMatchObject({ @@ -100,11 +112,12 @@ export default [codeownersJob({ input: ${JSON.stringify(scenario.input)} })]; await mkdir(path.join(rootDir, '.git')); await writeFile(path.join(rootDir, 'pullup.config.mjs'), customConfig); - await execFileAsync(process.execPath, [CLI_PATH, 'sync'], { + const sync = await execFileAsync(process.execPath, [CLI_PATH, 'sync'], { cwd: rootDir, timeout: 10_000, }); + expectScriptConfigWarning(sync, path.join(rootDir, 'pullup.config.mjs')); expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe( '/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n', ); @@ -125,7 +138,7 @@ export default [codeownersJob({ input: ${JSON.stringify(scenario.input)} })]; const sync = await execFileAsync(process.execPath, [CLI_PATH, 'sync'], { cwd: fixture.root, timeout: 10_000 }); expect(sync.stdout).toContain('codeowners synced'); - expect(sync.stderr).toBe(''); + expectScriptConfigWarning(sync, path.join(fixture.root, 'pullup.config.mjs')); expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe( '*.js @root-team\n' + '/packages/web/**/*.ts @frontend-team\n' + @@ -137,6 +150,6 @@ export default [codeownersJob({ input: ${JSON.stringify(scenario.input)} })]; const check = await execFileAsync(process.execPath, [CLI_PATH, 'check'], { cwd: fixture.root, timeout: 10_000 }); expect(check.stdout).toContain('All files are up to date'); - expect(check.stderr).toBe(''); + expectScriptConfigWarning(check, path.join(fixture.root, 'pullup.config.mjs')); }); }); diff --git a/tests/config-cli.spec.ts b/tests/config-cli.spec.ts index 44dfb14..0144574 100644 --- a/tests/config-cli.spec.ts +++ b/tests/config-cli.spec.ts @@ -23,8 +23,16 @@ const config = `jobs: output: .github/CODEOWNERS `; -function runCli(command: string, rootDir: string) { - return execFileAsync(process.execPath, [CLI_PATH, command], { +function scriptConfigWarning(configPath: string) { + return ( + `[DEPRECATED] JavaScript/TypeScript configuration is deprecated: ${configPath}\n` + + 'Use pullup.yml or pullup.yaml for built-in jobs and external commands for custom transforms.\n' + + 'Migration guide: https://github.com/toss/pull-up#migration\n\n' + ); +} + +function runCli(command: string, rootDir: string, args: string[] = []) { + return execFileAsync(process.execPath, [CLI_PATH, command, ...args], { cwd: rootDir, timeout: 10_000, }).then( @@ -137,9 +145,49 @@ describe('YAML config CLI', () => { const result = await runCli(command, fixture.root); expect(result.code).toBe(0); - expect(result.stderr).toBe(''); + expect(result.stderr).toBe(scriptConfigWarning(path.join(fixture.root, 'pullup.config.mjs'))); + expect(result.stderr.match(/\[DEPRECATED\]/g)).toHaveLength(1); + expect(result.stdout).not.toContain('[DEPRECATED]'); expect(result.stdout).toContain(command === 'sync' ? 'custom synced' : 'All files are up to date'); expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(generatedContents); }); }); }); + +describe('script config CLI deprecation warning', () => { + it('warns once for sync --dry-run while preserving the preview and existing output', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.config.mjs': scriptConfig, + '.github': { CODEOWNERS: existingContents }, + }); + + const result = await runCli('sync', fixture.root, ['--dry-run']); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(scriptConfigWarning(path.join(fixture.root, 'pullup.config.mjs'))); + expect(result.stderr.match(/\[DEPRECATED\]/g)).toHaveLength(1); + expect(result.stdout).not.toContain('[DEPRECATED]'); + expect(result.stdout).toContain('[Job] custom'); + expect(result.stdout).toContain('Output: .github/CODEOWNERS'); + expect(result.stdout).toContain(generatedContents.trimEnd()); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it('warns once without changing the outdated check failure or existing output', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.config.mjs': scriptConfig, + '.github': { CODEOWNERS: existingContents }, + }); + + const result = await runCli('check', fixture.root); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(scriptConfigWarning(path.join(fixture.root, 'pullup.config.mjs'))); + expect(result.stderr.match(/\[DEPRECATED\]/g)).toHaveLength(1); + expect(result.stderr).toContain("custom is outdated. Run 'pullup sync' to update."); + expect(result.stdout).toBe(''); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); +}); diff --git a/tests/config-schema.spec.ts b/tests/config-schema.spec.ts index 751c823..af6995d 100644 --- a/tests/config-schema.spec.ts +++ b/tests/config-schema.spec.ts @@ -4,29 +4,114 @@ describe('generated config schema', () => { it('describes job mappings and their fields for YAML editors', async () => { const schema = JSON.parse(await readFile(new URL('../schema.json', import.meta.url), 'utf8')); - expect(schema).toMatchObject({ - type: 'object', - properties: { - jobs: { - type: 'object', - propertyNames: { - type: 'string', - not: { enum: ['constructor', 'prototype', '__proto__'] }, - }, - additionalProperties: { - type: 'object', - properties: { - type: { const: 'codeowners' }, - input: { type: 'array', items: { type: 'string' } }, - output: { type: 'string' }, + expect(schema).toMatchInlineSnapshot(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "jobs": { + "additionalProperties": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "input": { + "description": "Input file paths for the job", + "examples": [ + [ + "**/CODEOWNERS", + "!**/fixtures/**", + ], + ], + "items": { + "type": "string", + }, + "type": "array", + }, + "output": { + "description": "Output file path for the job", + "examples": [ + "CODEOWNERS", + ".github/CODEOWNERS", + "docs/CODEOWNERS", + ], + "type": "string", + }, + "type": { + "const": "codeowners", + }, + }, + "required": [ + "type", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "command": { + "description": "The command to run for the custom job", + "examples": [ + "node scripts/generate-codeowners.js", + "python scripts/generate-codeowners.py", + ], + "minLength": 1, + "type": "string", + }, + "input": { + "description": "Input file paths for the job", + "examples": [ + [ + "**/CODEOWNERS", + "!**/fixtures/**", + ], + ], + "items": { + "type": "string", + }, + "type": "array", + }, + "output": { + "description": "Output file path for the job", + "examples": [ + "CODEOWNERS", + ".github/CODEOWNERS", + "docs/CODEOWNERS", + ], + "minLength": 1, + "type": "string", + }, + "type": { + "const": "custom", + }, + }, + "required": [ + "type", + "command", + "output", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "not": { + "enum": [ + "constructor", + "prototype", + "__proto__", + ], + }, + "type": "string", }, - required: ['type'], - additionalProperties: false, + "type": "object", }, }, - }, - required: ['jobs'], - additionalProperties: false, - }); + "required": [ + "jobs", + ], + "type": "object", + } + `); }); }); diff --git a/tests/output-read-errors.spec.ts b/tests/output-read-errors.spec.ts index 27cb19a..8899363 100644 --- a/tests/output-read-errors.spec.ts +++ b/tests/output-read-errors.spec.ts @@ -17,6 +17,20 @@ const config = `export default [ ]; `; +function scriptConfigWarning(rootDir: string) { + return ( + `[DEPRECATED] JavaScript/TypeScript configuration is deprecated: ${path.join(rootDir, 'pullup.config.mjs')}\n` + + 'Use pullup.yml or pullup.yaml for built-in jobs and external commands for custom transforms.\n' + + 'Migration guide: https://github.com/toss/pull-up#migration\n\n' + ); +} + +function expectScriptConfigWarning(result: { stdout: string; stderr: string }, rootDir: string) { + expect(result.stderr).toContain(scriptConfigWarning(rootDir)); + expect(result.stderr.match(/\[DEPRECATED\]/g)).toHaveLength(1); + expect(result.stdout).not.toContain('[DEPRECATED]'); +} + async function prepareRepository(rootDir: string) { await mkdir(path.join(rootDir, '.git')); await mkdir(path.join(rootDir, '.github'), { recursive: true }); @@ -44,6 +58,7 @@ describe('output read errors', () => { const result = await runCli(command, fixture.root); expect(result.code).toBe(1); + expectScriptConfigWarning(result, fixture.root); expect(result.stdout + result.stderr).toContain('EISDIR'); expect(result.stdout + result.stderr).toContain(outputPath); expect(result.stdout + result.stderr).not.toContain('outdated'); @@ -66,6 +81,7 @@ describe('output read errors', () => { const result = await runCli(command, fixture.root); expect(result.code).toBe(1); + expectScriptConfigWarning(result, fixture.root); expect(result.stdout + result.stderr).toContain('EACCES'); expect(result.stdout + result.stderr).toContain(outputPath); expect(result.stdout + result.stderr).not.toContain('outdated'); @@ -87,14 +103,16 @@ describe('output read errors', () => { const sync = await runCli('sync', fixture.root); expect(sync.code).toBe(0); - expect(sync.stderr).toBe(''); + expect(sync.stderr).toBe(scriptConfigWarning(fixture.root)); + expectScriptConfigWarning(sync, fixture.root); expect(sync.stdout).toContain('unreadable synced'); expect(await readFile(path.join(fixture.root, output), 'utf8')).toBe('generated output\n'); const check = await runCli('check', fixture.root); expect(check.code).toBe(0); - expect(check.stderr).toBe(''); + expect(check.stderr).toBe(scriptConfigWarning(fixture.root)); + expectScriptConfigWarning(check, fixture.root); expect(check.stdout).toContain('All files are up to date'); }); }); diff --git a/yarn.lock b/yarn.lock index 419d2b6..343354d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -684,6 +684,7 @@ __metadata: eslint: "npm:^9.7.0" eslint-plugin-import-x: "npm:^4.0.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" + execa: "npm:^10.0.1" fast-glob: "npm:^3.3.3" find-up: "npm:^8.0.0" globals: "npm:^17.0.0" @@ -1020,6 +1021,20 @@ __metadata: languageName: node linkType: hard +"@sec-ant/readable-stream@npm:^0.4.1": + version: 0.4.1 + resolution: "@sec-ant/readable-stream@npm:0.4.1" + checksum: 10c0/64e9e9cf161e848067a5bf60cdc04d18495dc28bb63a8d9f8993e4dd99b91ad34e4b563c85de17d91ffb177ec17a0664991d2e115f6543e73236a906068987af + languageName: node + linkType: hard + +"@sindresorhus/merge-streams@npm:^4.0.0": + version: 4.0.0 + resolution: "@sindresorhus/merge-streams@npm:4.0.0" + checksum: 10c0/482ee543629aa1933b332f811a1ae805a213681ecdd98c042b1c1b89387df63e7812248bb4df3910b02b3cc5589d3d73e4393f30e197c9dde18046ccd471fc6b + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -1975,6 +1990,26 @@ __metadata: languageName: node linkType: hard +"execa@npm:^10.0.1": + version: 10.0.1 + resolution: "execa@npm:10.0.1" + dependencies: + "@sindresorhus/merge-streams": "npm:^4.0.0" + figures: "npm:^6.1.0" + get-stream: "npm:^9.0.1" + human-signals: "npm:^8.0.1" + is-plain-obj: "npm:^4.1.0" + is-stream: "npm:^4.0.1" + npm-run-path: "npm:^6.0.0" + pretty-ms: "npm:^9.3.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^4.0.0" + which-command: "npm:^0.1.0" + yoctocolors: "npm:^2.1.2" + checksum: 10c0/5fc05e35729344d8fdddbc92d91d995cb9cb9080f953cd60367285a7001f1bca9a7deb6f421413a62e6f80d2c2e91af9cda6990b0a88e854cb414506b4b05fce + languageName: node + linkType: hard + "expect-type@npm:^1.4.0": version: 1.4.0 resolution: "expect-type@npm:1.4.0" @@ -2051,6 +2086,15 @@ __metadata: languageName: node linkType: hard +"figures@npm:^6.1.0": + version: 6.1.0 + resolution: "figures@npm:6.1.0" + dependencies: + is-unicode-supported: "npm:^2.0.0" + checksum: 10c0/9159df4264d62ef447a3931537de92f5012210cf5135c35c010df50a2169377581378149abfe1eb238bd6acbba1c0d547b1f18e0af6eee49e30363cedaffcfe4 + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -2157,6 +2201,16 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^9.0.1": + version: 9.0.1 + resolution: "get-stream@npm:9.0.1" + dependencies: + "@sec-ant/readable-stream": "npm:^0.4.1" + is-stream: "npm:^4.0.1" + checksum: 10c0/d70e73857f2eea1826ac570c3a912757dcfbe8a718a033fa0c23e12ac8e7d633195b01710e0559af574cbb5af101009b42df7b6f6b29ceec8dbdf7291931b948 + languageName: node + linkType: hard + "get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.0": version: 4.13.0 resolution: "get-tsconfig@npm:4.13.0" @@ -2242,6 +2296,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^8.0.1": + version: 8.0.1 + resolution: "human-signals@npm:8.0.1" + checksum: 10c0/195ac607108c56253757717242e17cd2e21b29f06c5d2dad362e86c672bf2d096e8a3bbb2601841c376c2301c4ae7cff129e87f740aa4ebff1390c163114c7c4 + languageName: node + linkType: hard + "iconv-lite@npm:^0.7.0": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" @@ -2319,6 +2380,20 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^4.1.0": + version: 4.1.0 + resolution: "is-plain-obj@npm:4.1.0" + checksum: 10c0/32130d651d71d9564dc88ba7e6fda0e91a1010a3694648e9f4f47bb6080438140696d3e3e15c741411d712e47ac9edc1a8a9de1fe76f3487b0d90be06ac9975e + languageName: node + linkType: hard + +"is-stream@npm:^4.0.1": + version: 4.0.1 + resolution: "is-stream@npm:4.0.1" + checksum: 10c0/2706c7f19b851327ba374687bc4a3940805e14ca496dc672b9629e744d143b1ad9c6f1b162dece81c7bfbc0f83b32b61ccc19ad2e05aad2dd7af347408f60c7f + languageName: node + linkType: hard + "is-subdir@npm:^1.1.1": version: 1.2.0 resolution: "is-subdir@npm:1.2.0" @@ -2328,6 +2403,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^2.0.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 + languageName: node + linkType: hard + "is-windows@npm:^1.0.0": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -2768,6 +2850,16 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^6.0.0": + version: 6.0.0 + resolution: "npm-run-path@npm:6.0.0" + dependencies: + path-key: "npm:^4.0.0" + unicorn-magic: "npm:^0.3.0" + checksum: 10c0/b223c8a0dcd608abf95363ea5c3c0ccc3cd877daf0102eaf1b0f2390d6858d8337fbb7c443af2403b067a7d2c116d10691ecd22ab3c5273c44da1ff8d07753bd + languageName: node + linkType: hard + "obug@npm:^2.1.1": version: 2.1.1 resolution: "obug@npm:2.1.1" @@ -2910,6 +3002,13 @@ __metadata: languageName: node linkType: hard +"parse-ms@npm:^4.0.0": + version: 4.0.0 + resolution: "parse-ms@npm:4.0.0" + checksum: 10c0/a7900f4f1ebac24cbf5e9708c16fb2fd482517fad353aecd7aefb8c2ba2f85ce017913ccb8925d231770404780df46244ea6fec598b3bde6490882358b4d2d16 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -2924,6 +3023,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -3009,6 +3115,15 @@ __metadata: languageName: node linkType: hard +"pretty-ms@npm:^9.3.0": + version: 9.3.1 + resolution: "pretty-ms@npm:9.3.1" + dependencies: + parse-ms: "npm:^4.0.0" + checksum: 10c0/f034e6197922da40d6f0e73c835baa0f87d89f660966deb651305ee0567e20d5258b9c1782de098069b479036653bd1ad4ee1d6d816d1fde0e47c62d0ce5763d + languageName: node + linkType: hard + "proc-log@npm:^7.0.0": version: 7.0.0 resolution: "proc-log@npm:7.0.0" @@ -3327,7 +3442,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 @@ -3395,6 +3510,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-final-newline@npm:4.0.0" + checksum: 10c0/b0cf2b62d597a1b0e3ebc42b88767f0a0d45601f89fd379a928a1812c8779440c81abba708082c946445af1d6b62d5f16e2a7cf4f30d9d6587b89425fae801ff + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -3887,6 +4009,15 @@ __metadata: languageName: node linkType: hard +"which-command@npm:^0.1.0": + version: 0.1.0 + resolution: "which-command@npm:0.1.0" + bin: + which-command: cli.js + checksum: 10c0/15b3952b466961ce147de197ef2d40d0f6242dac7d07785936541f7b8f6dba632bd6eab0abac362b80efc0029c536d044717c10aa96562831b0d0a7d548a0029 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -3945,3 +4076,10 @@ __metadata: checksum: 10c0/36d4793e9cf7060f9da543baf67c55e354f4862c8d3d34de1a1b1d7c382d44171315cc54abf84d8900b8113d742b830108a1434f4898fb244f9b7e8426d4b8f5 languageName: node linkType: hard + +"yoctocolors@npm:^2.1.2": + version: 2.2.0 + resolution: "yoctocolors@npm:2.2.0" + checksum: 10c0/ca179aa83401aa0711f268fda38145a44c828e612a5f5265439efb5796cd63c46ca6b786bae3b74c75440a48518c36b5b23cba7fbb76ea8af5a9f85f830a5754 + languageName: node + linkType: hard