Skip to content

Commit

Permalink
Add --config flag (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
raulfdm authored Aug 15, 2023
1 parent 10dc06f commit 9832e37
Show file tree
Hide file tree
Showing 16 changed files with 362 additions and 27 deletions.
11 changes: 11 additions & 0 deletions .changeset/strong-windows-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@codeowners-flow/cli': minor
---

Accept configuration path to generate CODEOWNERS

Now, users can point where the config file is located:

```bash
npx codeowners-flow generate -c ./path/to/config
```
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules/
dist/
.turbo
tsconfig.tsbuildinfo
.history
.history
coverage/
10 changes: 10 additions & 0 deletions examples/common-js/codeowners.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('@codeowners-flow/cli/config').UserConfig} */
module.exports = {
outDir: '.github',
rules: [
{
patterns: ['*'],
owners: [{ name: '@company/core-team' }],
},
],
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
defineConfig,
defineRule,
defineOwner,
defineRule,
} from '@codeowners-flow/cli/config';

const mainTeams = [
Expand Down
10 changes: 10 additions & 0 deletions examples/different-file-name/codeowners.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('@codeowners-flow/cli/config').UserConfig} */
export default {
outDir: '.github',
rules: [
{
patterns: ['*'],
owners: [{ name: '@company/core-team' }],
},
],
};
10 changes: 10 additions & 0 deletions examples/simple/codeowners.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('@codeowners-flow/cli/config').UserConfig} */
export default {
outDir: '.github',
rules: [
{
patterns: ['*'],
owners: [{ name: '@company/core-team' }],
},
],
};
15 changes: 15 additions & 0 deletions examples/using-define-fn/codeowners.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
defineConfig,
defineOwner,
defineRule,
} from '@codeowners-flow/cli/config';

export default defineConfig({
outDir: '.github',
rules: [
defineRule({
patterns: ['*'],
owners: [defineOwner({ name: '@company/core-team' })],
}),
],
});
8 changes: 8 additions & 0 deletions examples/yaml/codeowners.config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
outDir: .github
rules:
- patterns:
- '*'
- 'docs/**'
owners:
- name: '@company/core-team'
- name: '@company/infra-team'
15 changes: 15 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ To understand the motivations behind this project, refer to the [root repository
- Node 16 or later
- pnpm, yarn, or npm

## Usage

```
Usage
$ codeowners-flow generate
$ codeowners-flow generate --config /path/to/config.mjs
$ codeowners-flow init
Example
$ codeowners-flow generate
CODEOWNERS file generated! 🎉
You can find it at: "/path/to/CODEOWNERS".
```

## Getting started

The first step is to install `codeowners-flow` in your project:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@codeowners-flow/eslint": "workspace:*",
"@codeowners-flow/tsconfig": "workspace:*",
"@types/node": "16",
"@vitest/coverage-v8": "0.34.1",
"eslint": "8.47.0",
"meow": "12.0.1",
"tsx": "3.12.7",
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/config/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { loadUserConfig, type UserConfig } from './index.js';

const mockConfig: UserConfig = {
outDir: 'outDir',
rules: [
{
owners: [
{
name: 'ownerName',
},
],
patterns: ['*'],
comments: ['comment'],
excludePatterns: ['excludePatterns'],
},
],
};

const mockExploderLoad = vi.fn().mockResolvedValue({ config: mockConfig });
const mockExplorerSearch = vi.fn().mockResolvedValue({ config: mockConfig });
vi.mock('cosmiconfig', () => ({
cosmiconfig: () => {
return {
load: (...args: any) => mockExploderLoad(...args),
search: (...args: any) => mockExplorerSearch(...args),
};
},
}));

describe('fn: loadUserConfig', () => {
it('searches for config if no configRelativePath is sent', async () => {
await loadUserConfig('rootDir');
expect(mockExplorerSearch).toHaveBeenCalled();
});

it('loads the config sent', () => {
loadUserConfig('rootDir', 'configRelativePath');
expect(mockExploderLoad).toHaveBeenCalledWith(
expect.stringContaining('configRelativePath'),
);
});

it('returns the parsed configuration', async () => {
const config = await loadUserConfig('rootDir', 'configRelativePath');
expect(config).toEqual(mockConfig);
});

describe('error handling', () => {
it('throws an user-friendly error message about the schema', async () => {
const customConfig: Partial<UserConfig> = {
rules: [],
};

mockExplorerSearch.mockResolvedValue({ config: customConfig });

try {
await loadUserConfig('rootDir');
} catch (error) {
expect((error as Error).message).toMatchInlineSnapshot(
'"Validation error: Required at \\"outDir\\""',
);
}
});

it('throws an user-friendly error message if config file is not found', async () => {
mockExploderLoad.mockRejectedValue(
new Error('no such file or directory'),
);

try {
await loadUserConfig('rootDir', 'configRelativePath');
} catch (error) {
expect((error as Error).message).toMatchInlineSnapshot(
'"Config file not found. Please ensure to point a valid config file path or create a new one with the init command."',
);
}
});
});
});
40 changes: 28 additions & 12 deletions packages/cli/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import path from 'node:path';

import { cosmiconfig } from 'cosmiconfig';
import type { ZodError } from 'zod';
import { ZodError } from 'zod';
import { fromZodError } from 'zod-validation-error';

import {
Expand All @@ -12,21 +14,35 @@ import {

const explorer = cosmiconfig('codeowners');

export async function loadConfig() {
const config = await explorer.search();

export async function loadUserConfig(
rootDir: string,
configRelativePath?: string,
) {
try {
return UserConfigSchema.parse(config?.config ?? {});
const result = configRelativePath
? await explorer.load(path.resolve(rootDir, configRelativePath))
: await explorer.search();

return UserConfigSchema.parse(result?.config ?? {});
} catch (error: unknown) {
const validationError = fromZodError(error as ZodError);
// the error now is readable by the user
// you may print it to console
// or return it via an API
console.error(validationError.message);
console.log('\n');
process.exit(1);
let message = 'An unexpected error occurred.';

if (error instanceof ZodError) {
const validationError = fromZodError(error as ZodError);
message = validationError.message;
} else if (error instanceof Error) {
if (error.message.includes('no such file or directory')) {
message =
'Config file not found. Please ensure to point a valid config file path or create a new one with the init command.';
} else {
message = error.message;
}
}

throw new Error(message);
}
}

export type { UserConfig };

export { defineConfig, defineOwner, defineRule };
13 changes: 9 additions & 4 deletions packages/cli/src/generate-codeowners.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { UserConfig } from './config/index.js';
import { generateCodeOwners } from './generate-codeowners.js';

const mockLoadConfig = vi.fn();
const mockLoadUserConfig = vi.fn();
vi.mock('./config/index.js', () => ({
loadConfig: (...args: any) => mockLoadConfig(...args),
loadUserConfig: (...args: any) => mockLoadUserConfig(...args),
}));

const mockWriteFileSync = vi.fn();
Expand All @@ -20,7 +20,7 @@ vi.mock('@manypkg/find-root', () => ({

describe('fn: generateCodeOwners', () => {
beforeEach(() => {
mockLoadConfig.mockResolvedValue({
mockLoadUserConfig.mockResolvedValue({
outDir: './test',
rules: [
{
Expand All @@ -46,7 +46,7 @@ describe('fn: generateCodeOwners', () => {
expect(location).toBe('/root/test/CODEOWNERS');
});

it.concurrent('writes CODEOWNERS content', async () => {
it('writes CODEOWNERS content', async () => {
await generateCodeOwners();

const [, content] = mockWriteFileSync.mock.calls[0];
Expand Down Expand Up @@ -79,4 +79,9 @@ describe('fn: generateCodeOwners', () => {
}
`);
});

it('calls load config with custom config path', async () => {
await generateCodeOwners({ config: 'custom/path' });
expect(mockLoadUserConfig).toHaveBeenCalledWith('/root', 'custom/path');
});
});
26 changes: 18 additions & 8 deletions packages/cli/src/generate-codeowners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ import path from 'node:path';

import { findRoot } from '@manypkg/find-root';

import { loadConfig, type UserConfig } from './config/index.js';
import { loadUserConfig, type UserConfig } from './config/index.js';
import { getCodeOwnersContent } from './get-codeowners-content.js';

export async function generateCodeOwners(): Promise<{
type GenerateCodeOwnersOptions = {
config?: string;
};

export async function generateCodeOwners({
config,
}: GenerateCodeOwnersOptions = {}): Promise<{
ownersContent: string;
ownersPath: string;
}> {
const config = await loadConfig();
const { rootDir } = await findRoot(process.cwd());

const loadedConfig = await loadUserConfig(rootDir, config);

const ownersContent = getCodeOwnersContent(config);
const ownersPath = await getCodeOwnersPath(config);
const ownersContent = getCodeOwnersContent(loadedConfig);
const ownersPath = await getCodeOwnersPath(loadedConfig, rootDir);

writeCodeOwnersFile(ownersPath, ownersContent);

Expand All @@ -27,7 +35,9 @@ function writeCodeOwnersFile(path: string, content: string): void {
fs.writeFileSync(path, content, 'utf-8');
}

async function getCodeOwnersPath(userConfig: UserConfig): Promise<string> {
const root = await findRoot(process.cwd());
return path.join(root.rootDir, userConfig.outDir, 'CODEOWNERS');
async function getCodeOwnersPath(
userConfig: UserConfig,
rootPath: string,
): Promise<string> {
return path.join(rootPath, userConfig.outDir, 'CODEOWNERS');
}
9 changes: 8 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const cli = meow(
`
Usage
$ codeowners-flow generate
$ codeowners-flow generate --config /path/to/config.mjs
$ codeowners-flow init
Example
Expand All @@ -17,14 +18,20 @@ const cli = meow(
`,
{
importMeta: import.meta,
flags: {
config: {
type: 'string',
shortFlag: 'c',
},
},
},
);

const [command] = cli.input;

switch (command) {
case 'generate': {
generateCodeOwners()
generateCodeOwners(cli.flags)
.then(({ ownersPath }) => {
console.log('Codeowners file generated! 🎉');
console.log(`You can find it at: "${ownersPath}".\n`);
Expand Down
Loading

0 comments on commit 9832e37

Please sign in to comment.