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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ jobs:
- run: yarn install --immutable
- run: yarn lint
- run: yarn format:check
- run: yarn build
- run: yarn test
- run: yarn typecheck
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
dist

*.tgz

.vitest_cache
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
},
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"prettier.configPath": ".prettierrc.json",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always",
"source.fixAll.ts": "always",
"source.organizeImports": "always",
"source.removeUnusedImports": "always"
},
"js/ts.tsdk.path": ".yarn/sdks/typescript/lib",
"eslint.nodePath": ".yarn/sdks"
}
File renamed without changes.
File renamed without changes.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
],
"scripts": {
"build": "tsdown",
"test": "yarn build && node --test tests/*.test.mjs",
"test": "vitest run",
"dev": "tsdown --watch",
"prepack": "yarn build",
"lint": "eslint .",
Expand All @@ -54,7 +54,9 @@
"prettier": "^3.8.0",
"tsdown": "^0.19.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.27.0"
"typescript-eslint": "^8.27.0",
"vite": "^8.3.1",
"vitest": "^5.0.2"
},
"dependencies": {
"clipanion": "^4.0.0-rc.4",
Expand Down
224 changes: 224 additions & 0 deletions src/core/jobs/codeowners/codeowners-job.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { Fixture } from '@fixture-kit/core';

import { codeownersJob } from './codeowners-job';

const fixtureDirectory = fileURLToPath(new URL('../../../../fixtures/repo/', import.meta.url));

async function readFixtureFiles(rootDir: string, filePaths: string[]) {
return Promise.all(
filePaths.map(async (filePath) => ({
path: filePath,
contents: await readFile(path.resolve(rootDir, filePath), 'utf8'),
})),
);
}

describe('codeownersJob', () => {
it('merges parent CODEOWNERS before nested files', async () => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
// Lexical path order places ads-platform/CODEOWNERS before ads/CODEOWNERS.
const inputFiles = await readFixtureFiles(rootDir, [
path.join(rootDir, 'services/ads/ads-platform/CODEOWNERS'),
path.join(rootDir, 'services/ads/CODEOWNERS'),
]);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/services/ads/ @ads-team\n' +
'/services/ads/ads-platform/ @platform-team\n' +
'/services/ads/ads-platform/special/ @special-team\n',
);
});

it('keeps each directory subtree together with parents first', async () => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
const inputFiles = await readFixtureFiles(rootDir, [
path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'),
path.join(rootDir, 'services/auth-cert/CODEOWNERS'),
path.join(rootDir, 'services/auth/login/admin/CODEOWNERS'),
path.join(rootDir, 'services/auth/login/CODEOWNERS'),
path.join(rootDir, 'services/auth/CODEOWNERS'),
path.join(rootDir, 'CODEOWNERS'),
]);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/ @root-team\n' +
'/docs/ @docs-team\n' +
'/services/auth/ @auth-team\n' +
'/services/auth/login/ @login-team\n' +
'/services/auth/login/admin/ @admin-team\n' +
'/services/auth-cert/ @cert-team\n' +
'/tools/catalog-cli/ @tools-team\n',
);
});

it('groups nested directories even when their parent has no CODEOWNERS', async () => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
const inputFiles = await readFixtureFiles(rootDir, [
path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'),
path.join(rootDir, 'services/cart/CODEOWNERS'),
path.join(rootDir, 'services/builder/form/CODEOWNERS'),
path.join(rootDir, 'services/builder/desktop/CODEOWNERS'),
]);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/services/builder/desktop/ @desktop-team\n' +
'/services/builder/form/ @form-team\n' +
'/services/cart/ @cart-team\n' +
'/tools/catalog-cli/ @tools-team\n',
);
});

it('resolves relative and absolute CODEOWNERS paths against rootDir', async () => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
const inputFiles = await readFixtureFiles(rootDir, [
'services/auth/login/CODEOWNERS',
path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'),
'services/auth/CODEOWNERS',
'CODEOWNERS',
]);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/ @root-team\n' +
'/docs/ @docs-team\n' +
'/services/auth/ @auth-team\n' +
'/services/auth/login/ @login-team\n' +
'/tools/catalog-cli/ @tools-team\n',
);
});

it.each(['./services/auth/login/CODEOWNERS', 'services/./auth/../../services/auth/login/CODEOWNERS'])(
'normalizes %s before ordering parent and child rules',
async (childPath) => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
const inputFiles = await readFixtureFiles(rootDir, [childPath, 'services/auth/CODEOWNERS']);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe('/services/auth/ @auth-team\n/services/auth/login/ @login-team\n');
},
);

it('keeps normalized path aliases stable without mutating the input', async () => {
const rootDir = process.cwd();
const inputFiles = [
{ path: 'services/auth/CODEOWNERS', contents: '* @first\n' },
{
path: path.join(rootDir, 'services/auth/CODEOWNERS'),
contents: '* @second\n',
},
{
path: './services/auth/login/../CODEOWNERS',
contents: '* @last\n',
},
].map((file) => Object.freeze(file));
Object.freeze(inputFiles);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe('/services/auth/ @first\n' + '/services/auth/ @second\n' + '/services/auth/ @last\n');
});

it('keeps locale-equivalent directory names in separate subtrees', async () => {
const rootDir = process.cwd();
const inputFiles = [
{
path: 'services/é/child/CODEOWNERS',
contents: '* @composed-child\n',
},
{
path: 'services/e\u0301/CODEOWNERS',
contents: '* @decomposed-parent\n',
},
{
path: 'services/é/CODEOWNERS',
contents: '* @composed-parent\n',
},
{
path: 'services/e\u0301/child/CODEOWNERS',
contents: '* @decomposed-child\n',
},
];

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/services/e\u0301/ @decomposed-parent\n' +
'/services/e\u0301/child/ @decomposed-child\n' +
'/services/é/ @composed-parent\n' +
'/services/é/child/ @composed-child\n',
);
});

it('sorts filenames stably without mutating the input', async () => {
const rootDir = process.cwd();
const inputFiles = [
{
path: 'services/auth/Z-CODEOWNERS',
contents: '* @last\n',
},
{
path: 'services/auth/é-CODEOWNERS',
contents: '* @composed\n',
},
{
path: 'services/auth/e\u0301-CODEOWNERS',
contents: '* @decomposed\n',
},
{
path: 'services/auth/A-CODEOWNERS',
contents: '* @first\n',
},
].map((file) => Object.freeze(file));
Object.freeze(inputFiles);

const result = await codeownersJob().transform(inputFiles, {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(
'/services/auth/ @first\n' +
'/services/auth/ @composed\n' +
'/services/auth/ @decomposed\n' +
'/services/auth/ @last\n',
);
});
});
76 changes: 38 additions & 38 deletions tests/codeowners-cli.test.mjs → tests/codeowners-cli.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import { Fixture } from '@fixture-kit/core';

const execFileAsync = promisify(execFile);
const cliPath = fileURLToPath(new URL('../dist/bin/index.mjs', import.meta.url));
const coreUrl = new URL('../dist/core/index.mjs', import.meta.url).href;
const config = `import { codeownersJob } from ${JSON.stringify(coreUrl)};
const CLI_PATH = fileURLToPath(new URL('../dist/bin/index.mjs', import.meta.url));
const CORE_URL = new URL('../dist/core/index.mjs', import.meta.url).href;
const config = `import { codeownersJob } from ${JSON.stringify(CORE_URL)};
export default [codeownersJob()];
`;

for (const scenario of [
{ name: 'the repository root', cwd: 'repo', explicitRoot: false },
{ name: 'a subdirectory', cwd: 'repo/services/auth', explicitRoot: false },
{
name: 'another repository with --root',
cwd: 'other-repo',
explicitRoot: true,
},
]) {
test(`sync resolves CODEOWNERS paths from ${scenario.name}`, async () => {
await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('./fixtures/', import.meta.url)));
beforeAll(async () => {
await execFileAsync('yarn', ['build'], { cwd: fileURLToPath(new URL('../', import.meta.url)) });
});

describe('codeowners-cli', () => {
it.each([
{ name: 'the repository root', cwd: 'repo', explicitRoot: false },
{ name: 'a subdirectory', cwd: 'repo/services/auth', explicitRoot: false },
{
name: 'another repository with --root',
cwd: 'other-repo',
explicitRoot: true,
},
])('sync resolves CODEOWNERS paths from $name', async (scenario) => {
await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('../fixtures/', import.meta.url)));
const rootDir = path.join(fixture.root, 'repo');
const subdirectory = path.join(rootDir, 'services/auth');
const otherRoot = path.join(fixture.root, 'other-repo');
Expand All @@ -38,16 +40,15 @@ for (const scenario of [
for (const directory of [rootDir, subdirectory, otherRoot]) {
await writeFile(path.join(directory, 'pullup.config.mjs'), config);
}
const args = [cliPath, 'sync'];
const args = [CLI_PATH, 'sync'];
if (scenario.explicitRoot) args.push('--root', rootDir);

await execFileAsync(process.execPath, args, {
cwd: path.join(fixture.root, scenario.cwd),
timeout: 10_000,
});

assert.equal(
await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8'),
expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe(
'/ @root-team\n' +
'/docs/ @docs-team\n' +
'/services/ads/ @ads-team\n' +
Expand All @@ -63,38 +64,37 @@ for (const scenario of [
'/tools/catalog-cli/ @tools-team\n',
);
if (scenario.explicitRoot) {
await assert.rejects(readFile(path.join(otherRoot, '.github/CODEOWNERS'), 'utf8'), { code: 'ENOENT' });
await expect(readFile(path.join(otherRoot, '.github/CODEOWNERS'), 'utf8')).rejects.toMatchObject({
code: 'ENOENT',
});
}
});
}

for (const scenario of [
{
name: './',
input: ['services/auth/CODEOWNERS', './services/auth/login/CODEOWNERS'],
},
{
name: '..',
input: ['services/auth/CODEOWNERS', 'services/../services/auth/login/CODEOWNERS'],
},
]) {
test(`sync normalizes ${scenario.name} in CODEOWNERS input paths before sorting`, async () => {
await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('./fixtures/repo/', import.meta.url)));
it.each([
{
name: './',
input: ['services/auth/CODEOWNERS', './services/auth/login/CODEOWNERS'],
},
{
name: '..',
input: ['services/auth/CODEOWNERS', 'services/../services/auth/login/CODEOWNERS'],
},
])('sync normalizes $name in CODEOWNERS input paths before sorting', async (scenario) => {
await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('../fixtures/repo/', import.meta.url)));
const rootDir = fixture.root;
const customConfig = `import { codeownersJob } from ${JSON.stringify(coreUrl)};
const customConfig = `import { codeownersJob } from ${JSON.stringify(CORE_URL)};
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, [cliPath, 'sync'], {
await execFileAsync(process.execPath, [CLI_PATH, 'sync'], {
cwd: rootDir,
timeout: 10_000,
});

assert.equal(
await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8'),
expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe(
'/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n',
);
});
}
});
Loading
Loading