From a14584b485ab046c3c348bf7483dfeae422cb6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Mar 2020 13:19:24 +0100 Subject: [PATCH 1/2] [cli] Add CODEOWNERS support to create-plugin --- .../{ => create-plugin}/createPlugin.test.ts | 0 .../{ => create-plugin}/createPlugin.ts | 32 +++++++ .../create-plugin/lib/codeowners.test.ts | 49 +++++++++++ .../commands/create-plugin/lib/codeowners.ts | 88 +++++++++++++++++++ packages/cli/src/index.ts | 2 +- 5 files changed, 170 insertions(+), 1 deletion(-) rename packages/cli/src/commands/{ => create-plugin}/createPlugin.test.ts (100%) rename packages/cli/src/commands/{ => create-plugin}/createPlugin.ts (91%) create mode 100644 packages/cli/src/commands/create-plugin/lib/codeowners.test.ts create mode 100644 packages/cli/src/commands/create-plugin/lib/codeowners.ts 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 91% rename from packages/cli/src/commands/createPlugin.ts rename to packages/cli/src/commands/create-plugin/createPlugin.ts index 22679b2dfa..bd76ef6d21 100644 --- a/packages/cli/src/commands/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -25,6 +25,7 @@ import { resolve as resolvePath } from 'path'; import { realpathSync, existsSync } from 'fs'; import os from 'os'; import ora from 'ora'; +import { parseOwnerIds, addCodeownersEntry } from './lib/codeowners'; const MARKER_SUCCESS = chalk.green(` ✔︎\n`); const MARKER_FAILURE = chalk.red(` ✘\n`); @@ -343,16 +344,39 @@ const createPlugin = async () => { return true; }, }, + { + 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 codeownersPath = path.join(rootDir, '.github', 'CODEOWNERS'); const appPackage = resolvePath(rootDir, 'packages', 'app'); 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 +393,14 @@ const createPlugin = async () => { addPluginToApp(rootDir, answers.id); } + if (ownerIds && ownerIds.length) { + 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..96e9a2f76c --- /dev/null +++ b/packages/cli/src/commands/create-plugin/lib/codeowners.ts @@ -0,0 +1,88 @@ +/* + * 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'; + +const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; +const USER_ID_RE = /^@[-\w]+$/; +const EMAIL_RE = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; + +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 function addCodeownersEntry( + codeownersFilePath: string, + ownedPath: string, + ownerIds: string[], +): void { + const allLines = fs.readFileSync(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 = allLines + .filter(line => line[0] !== '#') + .map(line => line.split(/\s+/).filter(Boolean)) + .filter(items => items.length >= 2); + + const newDeclarationEntries = oldDeclarationEntries + .filter(items => items[0] !== '*') + .concat([[ownedPath, ...ownerIds]]) + .sort((l1, l2) => l1[0].localeCompare(l2[0])) + .concat([['*', '@spotify/backstage-core']]); + + // Calculate longest path to be able to align entries nicely + const longestOwnedPath = newDeclarationEntries.reduce( + (length, entry) => Math.max(length, entry[0].length), + 0, + ); + + const newDeclarationLines = newDeclarationEntries.map(entry => { + const entryPath = entry[0] + ' '.repeat(longestOwnedPath - entry[0].length); + const entryOwners = entry.slice(1); + return [entryPath, ...entryOwners].join(' '); + }); + + const newLines = [...commentLines, '', ...newDeclarationLines, '']; + + fs.writeFileSync(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'; From 231c991fe064e836a1459e3991799028e43a5165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 22 Mar 2020 20:59:53 +0100 Subject: [PATCH 2/2] Address review comments --- .../commands/create-plugin/createPlugin.ts | 27 ++++++--- .../commands/create-plugin/lib/codeowners.ts | 59 ++++++++++++++----- scripts/cli-e2e-test.js | 2 + 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index bd76ef6d21..112377cbda 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -25,7 +25,11 @@ import { resolve as resolvePath } from 'path'; import { realpathSync, existsSync } from 'fs'; import os from 'os'; import ora from 'ora'; -import { parseOwnerIds, addCodeownersEntry } from './lib/codeowners'; +import { + parseOwnerIds, + addCodeownersEntry, + getCodeownersFilePath, +} from './lib/codeowners'; const MARKER_SUCCESS = chalk.green(` ✔︎\n`); const MARKER_FAILURE = chalk.red(` ✘\n`); @@ -328,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,7 +351,10 @@ const createPlugin = async () => { return true; }, }, - { + ]; + + if (codeownersPath) { + questions.push({ type: 'input', name: 'owner', message: chalk.blue( @@ -364,14 +374,13 @@ const createPlugin = async () => { return true; }, - }, - ]; + }); + } + const answers: Answers = await inquirer.prompt(questions); - const rootDir = realpathSync(process.cwd()); - const codeownersPath = path.join(rootDir, '.github', 'CODEOWNERS'); 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); @@ -394,8 +403,8 @@ const createPlugin = async () => { } if (ownerIds && ownerIds.length) { - addCodeownersEntry( - codeownersPath, + await addCodeownersEntry( + codeownersPath!, path.join('plugins', answers.id), ownerIds, ); diff --git a/packages/cli/src/commands/create-plugin/lib/codeowners.ts b/packages/cli/src/commands/create-plugin/lib/codeowners.ts index 96e9a2f76c..1ffa640f04 100644 --- a/packages/cli/src/commands/create-plugin/lib/codeowners.ts +++ b/packages/cli/src/commands/create-plugin/lib/codeowners.ts @@ -15,10 +15,35 @@ */ import fs from 'fs-extra'; +import path from 'path'; const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; const USER_ID_RE = /^@[-\w]+$/; -const EMAIL_RE = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; +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') { @@ -43,12 +68,12 @@ export function parseOwnerIds( return ids; } -export function addCodeownersEntry( +export async function addCodeownersEntry( codeownersFilePath: string, ownedPath: string, ownerIds: string[], -): void { - const allLines = fs.readFileSync(codeownersFilePath, 'utf8').split('\n'); +): Promise { + const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n'); // Only keep comments from the top of the file const commentLines = []; @@ -59,30 +84,34 @@ export function addCodeownersEntry( commentLines.push(line); } - const oldDeclarationEntries = allLines + const oldDeclarationEntries: CodeownersEntry[] = allLines .filter(line => line[0] !== '#') .map(line => line.split(/\s+/).filter(Boolean)) - .filter(items => items.length >= 2); + .filter(tokens => tokens.length >= 2) + .map(tokens => ({ + ownedPath: tokens[0], + ownerIds: tokens.slice(1), + })); const newDeclarationEntries = oldDeclarationEntries - .filter(items => items[0] !== '*') - .concat([[ownedPath, ...ownerIds]]) - .sort((l1, l2) => l1[0].localeCompare(l2[0])) - .concat([['*', '@spotify/backstage-core']]); + .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[0].length), + (length, entry) => Math.max(length, entry.ownedPath.length), 0, ); const newDeclarationLines = newDeclarationEntries.map(entry => { - const entryPath = entry[0] + ' '.repeat(longestOwnedPath - entry[0].length); - const entryOwners = entry.slice(1); - return [entryPath, ...entryOwners].join(' '); + const entryPath = + entry.ownedPath + ' '.repeat(longestOwnedPath - entry.ownedPath.length); + return [entryPath, ...entry.ownerIds].join(' '); }); const newLines = [...commentLines, '', ...newDeclarationLines, '']; - fs.writeFileSync(codeownersFilePath, newLines.join('\n'), 'utf8'); + await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8'); } 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);