Merge pull request #340 from spotify/freben/codeowners

[cli] Add CODEOWNERS support to create-plugin
This commit is contained in:
Fredrik Adelöw
2020-03-23 08:52:15 +01:00
committed by GitHub
6 changed files with 212 additions and 3 deletions
@@ -25,6 +25,11 @@ import { resolve as resolvePath } from 'path';
import { realpathSync, existsSync } from 'fs';
import os from 'os';
import ora from 'ora';
import {
parseOwnerIds,
addCodeownersEntry,
getCodeownersFilePath,
} from './lib/codeowners';
const MARKER_SUCCESS = chalk.green(` ✔︎\n`);
const MARKER_FAILURE = chalk.red(`\n`);
@@ -327,6 +332,9 @@ export const movePlugin = (
};
const createPlugin = async () => {
const rootDir = realpathSync(process.cwd());
const codeownersPath = await getCodeownersFilePath(rootDir);
const questions: Question[] = [
{
type: 'input',
@@ -344,15 +352,40 @@ const createPlugin = async () => {
},
},
];
if (codeownersPath) {
questions.push({
type: 'input',
name: 'owner',
message: chalk.blue(
'Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional]',
),
validate: (value: any) => {
if (!value) {
return true;
}
const ownerIds = parseOwnerIds(value);
if (!ownerIds) {
return chalk.red(
'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses of users (e.g. user@example.com).',
);
}
return true;
},
});
}
const answers: Answers = await inquirer.prompt(questions);
const rootDir = realpathSync(process.cwd());
const appPackage = resolvePath(rootDir, 'packages', 'app');
const cliPackage = resolvePath(__dirname, '..', '..');
const cliPackage = resolvePath(__dirname, '..', '..', '..');
const templateFolder = resolvePath(cliPackage, 'templates', 'default-plugin');
const tempDir = path.join(os.tmpdir(), answers.id);
const pluginDir = path.join(rootDir, 'plugins', answers.id);
const version = require(resolvePath(cliPackage, 'package.json')).version;
const ownerIds = parseOwnerIds(answers.owner);
console.log();
console.log(chalk.green('Creating the plugin...'));
@@ -369,6 +402,14 @@ const createPlugin = async () => {
addPluginToApp(rootDir, answers.id);
}
if (ownerIds && ownerIds.length) {
await addCodeownersEntry(
codeownersPath!,
path.join('plugins', answers.id),
ownerIds,
);
}
console.log();
console.log(
chalk.green(
@@ -0,0 +1,49 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isValidSingleOwnerId, parseOwnerIds } from './codeowners';
describe('codeowners', () => {
it('isValidSingleOwnerId', () => {
[
'@foo',
'@a-b',
'@org-a/team-a',
'@a/b',
'adam_driver+spam@deathstar.com',
].forEach(id => {
expect(isValidSingleOwnerId(id)).toBeTruthy();
});
[
'',
'@',
'@/team-a',
'@orsdsd/',
'adam_driver@deathstar',
'something',
].forEach(id => {
expect(isValidSingleOwnerId(id)).toBeFalsy();
});
});
it('parseOwnerIds', () => {
expect(parseOwnerIds('')).toBeUndefined();
expect(parseOwnerIds('@foo')).toEqual(['@foo']);
expect(parseOwnerIds(' @foo @bar/baz ')).toEqual(['@foo', '@bar/baz']);
expect(parseOwnerIds(' @foo @bar/ ')).toBeUndefined();
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import path from 'path';
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
const USER_ID_RE = /^@[-\w]+$/;
const EMAIL_RE = /^[^@]+@[-.\w]+\.[-\w]+$/i;
type CodeownersEntry = {
ownedPath: string;
ownerIds: string[];
};
export async function getCodeownersFilePath(
rootDir: string,
): Promise<string | undefined> {
const paths = [
path.join(rootDir, '.github', 'CODEOWNERS'),
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
path.join(rootDir, 'docs', 'CODEOWNERS'),
path.join(rootDir, 'CODEOWNERS'),
];
for (const p of paths) {
if (await fs.pathExists(p)) {
return p;
}
}
return undefined;
}
export function isValidSingleOwnerId(id: string): boolean {
if (!id || typeof id !== 'string') {
return false;
}
return TEAM_ID_RE.test(id) || USER_ID_RE.test(id) || EMAIL_RE.test(id);
}
export function parseOwnerIds(
spaceSeparatedOwnerIds: string,
): string[] | undefined {
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
return undefined;
}
const ids = spaceSeparatedOwnerIds.split(' ').filter(Boolean);
if (!ids.every(isValidSingleOwnerId)) {
return undefined;
}
return ids;
}
export async function addCodeownersEntry(
codeownersFilePath: string,
ownedPath: string,
ownerIds: string[],
): Promise<void> {
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
// Only keep comments from the top of the file
const commentLines = [];
for (const line of allLines) {
if (line[0] !== '#') {
break;
}
commentLines.push(line);
}
const oldDeclarationEntries: CodeownersEntry[] = allLines
.filter(line => line[0] !== '#')
.map(line => line.split(/\s+/).filter(Boolean))
.filter(tokens => tokens.length >= 2)
.map(tokens => ({
ownedPath: tokens[0],
ownerIds: tokens.slice(1),
}));
const newDeclarationEntries = oldDeclarationEntries
.filter(entry => entry.ownedPath !== '*')
.concat([{ ownedPath, ownerIds }])
.sort((l1, l2) => l1.ownedPath.localeCompare(l2.ownedPath))
.concat([{ ownedPath: '*', ownerIds: ['@spotify/backstage-core'] }]);
// Calculate longest path to be able to align entries nicely
const longestOwnedPath = newDeclarationEntries.reduce(
(length, entry) => Math.max(length, entry.ownedPath.length),
0,
);
const newDeclarationLines = newDeclarationEntries.map(entry => {
const entryPath =
entry.ownedPath + ' '.repeat(longestOwnedPath - entry.ownedPath.length);
return [entryPath, ...entry.ownerIds].join(' ');
});
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
}
+1 -1
View File
@@ -17,7 +17,7 @@
import program from 'commander';
import chalk from 'chalk';
import fs from 'fs';
import createPluginCommand from './commands/createPlugin';
import createPluginCommand from './commands/create-plugin/createPlugin';
import watch from './commands/watch-deps';
import lintCommand from './commands/lint';
import testCommand from './commands/testCommand';
+2
View File
@@ -37,6 +37,8 @@ async function main() {
const createPlugin = spawnPiped(['yarn', 'create-plugin']);
createPlugin.stdin.write('test-plugin\n');
await new Promise(resolve => setTimeout(resolve, 2000));
createPlugin.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(createPlugin);