diff --git a/packages/cli/src/commands/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts similarity index 100% rename from packages/cli/src/commands/createPlugin.test.ts rename to packages/cli/src/commands/create-plugin/createPlugin.test.ts diff --git a/packages/cli/src/commands/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts similarity index 90% rename from packages/cli/src/commands/createPlugin.ts rename to packages/cli/src/commands/create-plugin/createPlugin.ts index 22679b2dfa..112377cbda 100644 --- a/packages/cli/src/commands/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -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( diff --git a/packages/cli/src/commands/create-plugin/lib/codeowners.test.ts b/packages/cli/src/commands/create-plugin/lib/codeowners.test.ts new file mode 100644 index 0000000000..89386522f5 --- /dev/null +++ b/packages/cli/src/commands/create-plugin/lib/codeowners.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/commands/create-plugin/lib/codeowners.ts b/packages/cli/src/commands/create-plugin/lib/codeowners.ts new file mode 100644 index 0000000000..1ffa640f04 --- /dev/null +++ b/packages/cli/src/commands/create-plugin/lib/codeowners.ts @@ -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 { + 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 { + 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'); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4797cceaf2..5f24190237 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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'; diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 8d2535fdc3..10be71b1c4 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -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);