diff --git a/.changeset/chilled-melons-smash.md b/.changeset/chilled-melons-smash.md new file mode 100644 index 0000000000..ca8f02ff37 --- /dev/null +++ b/.changeset/chilled-melons-smash.md @@ -0,0 +1,20 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Add `github:branch-protection:create` scaffolder action to set branch protection on an existing repository. Example usage: + +```yaml +- id: set-branch-protection + name: Set Branch Protection + action: github:branch-protection:create + input: + repoUrl: 'github.com?repo=backstage&owner=backstage' + branch: master + enforceAdmins: true # default + requiredApprovingReviewCount: 1 # default + requireBranchesToBeUpToDate: true # default + requireCodeOwnerReviews: true + dismissStaleReviews: true + requiredConversationResolution: true +``` diff --git a/.changeset/friendly-coins-approve.md b/.changeset/friendly-coins-approve.md new file mode 100644 index 0000000000..71a8240089 --- /dev/null +++ b/.changeset/friendly-coins-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Fixed unexpected behaviour where configuration supplied with `APP_CONFIG_*` environment variables where not filtered by the configuration schema. diff --git a/.changeset/green-cooks-sort.md b/.changeset/green-cooks-sort.md new file mode 100644 index 0000000000..51dd86ac2e --- /dev/null +++ b/.changeset/green-cooks-sort.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Adding negation keyword for entity filtering diff --git a/.changeset/healthy-shoes-judge.md b/.changeset/healthy-shoes-judge.md new file mode 100644 index 0000000000..eebd89a23d --- /dev/null +++ b/.changeset/healthy-shoes-judge.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch +--- + +Add `reviewers` input parameter to `publish:bitbucketServer:pull-request` diff --git a/.changeset/renovate-87a3bd2.md b/.changeset/renovate-87a3bd2.md new file mode 100644 index 0000000000..c14def75b7 --- /dev/null +++ b/.changeset/renovate-87a3bd2.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-azure': patch +--- + +Updated dependency `azure-devops-node-api` to `^14.0.0`. diff --git a/.changeset/rotten-camels-deny.md b/.changeset/rotten-camels-deny.md new file mode 100644 index 0000000000..f5660d953a --- /dev/null +++ b/.changeset/rotten-camels-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Support `--max-warnings` flag for package linting diff --git a/.changeset/sharp-lamps-fix.md b/.changeset/sharp-lamps-fix.md new file mode 100644 index 0000000000..52de6690fe --- /dev/null +++ b/.changeset/sharp-lamps-fix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Add tests for the `useTemplateDirectory` hook. diff --git a/OWNERS.md b/OWNERS.md index b145205539..44f4755b8e 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -185,11 +185,11 @@ Scope: The Backstage Documentation ## Sponsors -| Name | Organization | GitHub | Email | -| ----------------- | ------------ | ------------------------------------------- | ------------------ | -| Niklas Gustavsson | Spotify | [protocol7](https://github.com/protocol7) | ngn@spotify.com | -| Dave Zolotusky | Spotify | [dzolotusky](https://github.com/dzolotusky) | dzolo@spotify.com | -| Helen Greul | Spotify | [helengreul](https://github.com/helengreul) | heleng@spotify.com | +| Name | Organization | GitHub | Email | +| ----------------- | ------------ | ------------------------------------------- | ----------------- | +| Niklas Gustavsson | Spotify | [protocol7](https://github.com/protocol7) | ngn@spotify.com | +| Dave Zolotusky | Spotify | [dzolotusky](https://github.com/dzolotusky) | dzolo@spotify.com | +| Pia Nilsson | Spotify | [pianilsson](https://github.com/pianilsson) | pia@spotify.com | ## Organization Members @@ -224,9 +224,10 @@ Scope: The Backstage Documentation ## Emeritus End User Sponsors -| Name | Organization | GitHub | Discord | -| --------- | ------------ | ------------------------------------------- | -------------- | -| Lee Mills | Spotify | [leemills83](https://github.com/leemills83) | `.binarypoint` | +| Name | Organization | GitHub | Discord | +| ----------- | ------------ | ------------------------------------------- | -------------- | +| Lee Mills | Spotify | [leemills83](https://github.com/leemills83) | `.binarypoint` | +| Helen Greul | Spotify | [helengreul](https://github.com/helengreul) | `helen_greul` | ## Emeritus Project Area Maintainers diff --git a/packages/backend-legacy/package.json b/packages/backend-legacy/package.json index d7d4a68b4a..0dd76afce2 100644 --- a/packages/backend-legacy/package.json +++ b/packages/backend-legacy/package.json @@ -66,7 +66,7 @@ "@backstage/plugin-techdocs-backend": "workspace:^", "@gitbeaker/node": "^35.1.0", "@octokit/rest": "^19.0.3", - "azure-devops-node-api": "^12.0.0", + "azure-devops-node-api": "^14.0.0", "better-sqlite3": "^11.0.0", "dockerode": "^4.0.0", "example-app": "link:../app", diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 2eb9bb8dea..70dc2614cc 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -237,6 +237,7 @@ Usage: backstage-cli package lint [options] [directories...] Options: --format --fix + --max-warnings -h, --help ``` diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 7cc23300af..b54536cd55 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -156,6 +156,10 @@ export function registerScriptCommand(program: Command) { 'eslint-formatter-friendly', ) .option('--fix', 'Attempt to automatically fix violations') + .option( + '--max-warnings ', + 'Fail if more than this number of warnings (default: 0)', + ) .description('Lint a package') .action(lazy(() => import('./lint').then(m => m.default))); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 87c353f56f..8816221fb4 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -29,6 +29,13 @@ export default async (directories: string[], opts: OptionValues) => { directories.length ? directories : ['.'], ); + const maxWarnings = opts.maxWarnings ?? 0; + + const failed = + results.some(r => r.errorCount > 0) || + results.reduce((current, next) => current + next.warningCount, 0) > + maxWarnings; + if (opts.fix) { await ESLint.outputFixes(results); } @@ -39,12 +46,14 @@ export default async (directories: string[], opts: OptionValues) => { if (opts.format === 'eslint-formatter-friendly') { process.chdir(paths.targetRoot); } + const resultText = formatter.format(results); - // If there is any feedback at all, we treat it as a lint failure. This should be - // consistent with our old behavior of passing `--max-warnings=0` when invoking eslint. if (resultText) { console.log(resultText); + } + + if (failed) { process.exit(1); } }; diff --git a/packages/cli/src/commands/repo/build.ts b/packages/cli/src/commands/repo/build.ts index 3ed528cd50..7e6d235e7d 100644 --- a/packages/cli/src/commands/repo/build.ts +++ b/packages/cli/src/commands/repo/build.ts @@ -27,57 +27,7 @@ import { import { runParallelWorkers } from '../../lib/parallel'; import { buildFrontend } from '../build/buildFrontend'; import { buildBackend } from '../build/buildBackend'; - -function createScriptOptionsParser(anyCmd: Command, commandPath: string[]) { - // Regardless of what command instance is passed in we want to find - // the root command and resolve the path from there - let rootCmd = anyCmd; - while (rootCmd.parent) { - rootCmd = rootCmd.parent; - } - - // Now find the command that was requested - let targetCmd = rootCmd as Command | undefined; - for (const name of commandPath) { - targetCmd = targetCmd?.commands.find(c => c.name() === name) as - | Command - | undefined; - } - - if (!targetCmd) { - throw new Error( - `Could not find package command '${commandPath.join(' ')}'`, - ); - } - const cmd = targetCmd; - - const expectedScript = `backstage-cli ${commandPath.join(' ')}`; - - return (scriptStr?: string) => { - if (!scriptStr || !scriptStr.startsWith(expectedScript)) { - return undefined; - } - - const argsStr = scriptStr.slice(expectedScript.length).trim(); - - // Can't clone or copy or even use commands as prototype, so we mutate - // the necessary members instead, and then reset them once we're done - const currentOpts = (cmd as any)._optionValues; - const currentStore = (cmd as any)._storeOptionsAsProperties; - - const result: Record = {}; - (cmd as any)._storeOptionsAsProperties = false; - (cmd as any)._optionValues = result; - - // Triggers the writing of options to the result object - cmd.parseOptions(argsStr.split(' ')); - - (cmd as any)._storeOptionsAsProperties = currentOpts; - (cmd as any)._optionValues = currentStore; - - return result; - }; -} +import { createScriptOptionsParser } from './optionsParser'; export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index 05fbf7b87e..d0d6ee8f06 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -15,11 +15,12 @@ */ import chalk from 'chalk'; -import { OptionValues } from 'commander'; +import { Command, OptionValues } from 'commander'; import { relative as relativePath } from 'path'; import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { runWorkerQueueThreads } from '../../lib/parallel'; +import { createScriptOptionsParser } from './optionsParser'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; @@ -29,7 +30,7 @@ function depCount(pkg: BackstagePackageJson) { return deps + devDeps; } -export async function command(opts: OptionValues): Promise { +export async function command(opts: OptionValues, cmd: Command): Promise { let packages = await PackageGraph.listTargetPackages(); if (opts.since) { @@ -54,22 +55,30 @@ export async function command(opts: OptionValues): Promise { process.env.FORCE_COLOR = '1'; } + const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']); + const resultsList = await runWorkerQueueThreads({ items: packages.map(pkg => ({ fullDir: pkg.dir, relativeDir: relativePath(paths.targetRoot, pkg.dir), + lintOptions: parseLintScript(pkg.packageJson.scripts?.lint), })), workerData: { fix: Boolean(opts.fix), format: opts.format as string | undefined, }, workerFactory: async ({ fix, format }) => { - const { ESLint } = require('eslint'); + const { ESLint } = require('eslint') as typeof import('eslint'); return async ({ fullDir, relativeDir, - }): Promise<{ relativeDir: string; resultText: string }> => { + lintOptions, + }): Promise<{ + relativeDir: string; + resultText: string; + failed: boolean; + }> => { // Bit of a hack to make file resolutions happen from the correct directory // since some lint rules don't respect the cwd of ESLint process.cwd = () => fullDir; @@ -92,21 +101,34 @@ export async function command(opts: OptionValues): Promise { await ESLint.outputFixes(results); } - const resultText = formatter.format(results); + const maxWarnings = lintOptions?.maxWarnings ?? 0; + const resultText = formatter.format(results) as string; + const failed = + results.some(r => r.errorCount > 0) || + results.reduce((current, next) => current + next.warningCount, 0) > + maxWarnings; - return { relativeDir, resultText }; + return { + relativeDir, + resultText, + failed, + }; }; }, }); let failed = false; - for (const { relativeDir, resultText } of resultsList) { - if (resultText) { - console.log(); - console.log(chalk.red(`Lint failed in ${relativeDir}:`)); - console.log(resultText.trimStart()); - + for (const { relativeDir, resultText, failed: runFailed } of resultsList) { + if (runFailed) { + console.log(chalk.red(`Lint failed in ${relativeDir}`)); failed = true; + + // When doing repo lint, only list the results if the lint failed to avoid a log + // dump of all warnings that might be irrelevant + if (resultText) { + console.log(); + console.log(resultText.trimStart()); + } } } diff --git a/packages/cli/src/commands/repo/optionsParser.ts b/packages/cli/src/commands/repo/optionsParser.ts new file mode 100644 index 0000000000..45c7eac0aa --- /dev/null +++ b/packages/cli/src/commands/repo/optionsParser.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { Command } from 'commander'; + +export function createScriptOptionsParser( + anyCmd: Command, + commandPath: string[], +) { + // Regardless of what command instance is passed in we want to find + // the root command and resolve the path from there + let rootCmd = anyCmd; + while (rootCmd.parent) { + rootCmd = rootCmd.parent; + } + + // Now find the command that was requested + let targetCmd = rootCmd as Command | undefined; + for (const name of commandPath) { + targetCmd = targetCmd?.commands.find(c => c.name() === name) as + | Command + | undefined; + } + + if (!targetCmd) { + throw new Error( + `Could not find package command '${commandPath.join(' ')}'`, + ); + } + const cmd = targetCmd; + + const expectedScript = `backstage-cli ${commandPath.join(' ')}`; + + return (scriptStr?: string) => { + if (!scriptStr || !scriptStr.startsWith(expectedScript)) { + return undefined; + } + + const argsStr = scriptStr.slice(expectedScript.length).trim(); + + // Can't clone or copy or even use commands as prototype, so we mutate + // the necessary members instead, and then reset them once we're done + const currentOpts = (cmd as any)._optionValues; + const currentStore = (cmd as any)._storeOptionsAsProperties; + + const result: Record = {}; + (cmd as any)._storeOptionsAsProperties = false; + (cmd as any)._optionValues = result; + + // Triggers the writing of options to the result object + cmd.parseOptions(argsStr.split(' ')); + + (cmd as any)._storeOptionsAsProperties = currentOpts; + (cmd as any)._optionValues = currentStore; + + return result; + }; +} diff --git a/plugins/app-backend/src/lib/config/readFrontendConfig.test.ts b/plugins/app-backend/src/lib/config/readFrontendConfig.test.ts new file mode 100644 index 0000000000..a4089b848a --- /dev/null +++ b/plugins/app-backend/src/lib/config/readFrontendConfig.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { createMockDirectory } from '@backstage/backend-test-utils'; +import { readFrontendConfig } from './readFrontendConfig'; +import { ConfigReader } from '@backstage/config'; + +describe('readFrontendConfig', () => { + const mockDir = createMockDirectory(); + + afterEach(() => { + mockDir.clear(); + }); + + it('should validate env config', async () => { + mockDir.setContent({ + 'appDir/.config-schema.json': JSON.stringify({ + schemas: [ + { + value: { + type: 'object', + + properties: { + app: { + type: 'object', + properties: { + secretOfLife: { + type: 'string', + visibility: 'secret', + }, + backendConfig: { + type: 'string', + visibility: 'backend', + }, + publicValue: { + type: 'string', + visibility: 'frontend', + }, + }, + }, + }, + }, + }, + ], + backstageConfigSchemaVersion: 1, + }), + }); + + const config = new ConfigReader({ + app: { + secretOfLife: '42', + backendConfig: 'backend', + publicValue: 'public', + }, + }); + + const frontendConfig = await readFrontendConfig({ + env: { + APP_CONFIG_app_secretOfLife: 'ignored', + APP_CONFIG_app_backendConfig: 'ignored', + APP_CONFIG_app_publicValue: 'injected', + }, + appDistDir: `${mockDir.path}/appDir`, + config, + }); + + expect(frontendConfig).toEqual([ + { + context: 'env', + data: { + app: { + publicValue: 'injected', + }, + }, + deprecatedKeys: [], + filteredKeys: undefined, + }, + { + context: 'app', + data: { app: { publicValue: 'public' } }, + deprecatedKeys: [], + filteredKeys: undefined, + }, + ]); + }); +}); diff --git a/plugins/app-backend/src/lib/config/readFrontendConfig.ts b/plugins/app-backend/src/lib/config/readFrontendConfig.ts index becd1c2f52..f2b22b720f 100644 --- a/plugins/app-backend/src/lib/config/readFrontendConfig.ts +++ b/plugins/app-backend/src/lib/config/readFrontendConfig.ts @@ -36,10 +36,9 @@ export async function readFrontendConfig(options: { }): Promise { const { env, appDistDir, config } = options; - const appConfigs = readEnvConfig(env); - const schemaPath = resolvePath(appDistDir, '.config-schema.json'); if (await fs.pathExists(schemaPath)) { + const envConfigs = readEnvConfig(env); const serializedSchema = await fs.readJson(schemaPath); try { @@ -49,11 +48,10 @@ export async function readFrontendConfig(options: { serialized: serializedSchema, })); - const frontendConfigs = await schema.process( - [{ data: config.get() as JsonObject, context: 'app' }], + return await schema.process( + [...envConfigs, { data: config.get() as JsonObject, context: 'app' }], { visibility: ['frontend'], withDeprecatedKeys: true }, ); - appConfigs.push(...frontendConfigs); } catch (error) { throw new Error( 'Invalid app bundle schema. If this error is unexpected you need to run `yarn build` in the app. ' + @@ -63,5 +61,5 @@ export async function readFrontendConfig(options: { } } - return appConfigs; + return []; } diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts index 616091739e..eda5def76e 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts @@ -93,6 +93,20 @@ describe('parseFilterExpression', () => { ); }); + it('recognizes negation key', () => { + const component = { kind: 'Component' } as unknown as Entity; + expect(run('not:kind:user')(component)).toBe(true); + }); + + it('supports negation and affirmative expressions', () => { + const component = { + kind: 'Component', + spec: { type: 'service' }, + } as unknown as Entity; + expect(run('not:kind:user type:service')(component)).toBe(true); + expect(run('type:service not:kind:user')(component)).toBe(true); + }); + it('rejects unknown keys', () => { expect(() => run('unknown:foo')).toThrowErrorMatchingInlineSnapshot( `"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`, @@ -123,17 +137,25 @@ describe('splitFilterExpression', () => { expect(run('')).toEqual([]); expect(run(' ')).toEqual([]); expect(run('kind:component')).toEqual([ - { key: 'kind', parameters: ['component'] }, + { key: 'kind', parameters: ['component'], negation: false }, ]); expect(run('kind:component,user')).toEqual([ - { key: 'kind', parameters: ['component', 'user'] }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, + ]); + expect(run('kind:component,user not:type:foo')).toEqual([ + { key: 'kind', parameters: ['component', 'user'], negation: false }, + { key: 'type', parameters: ['foo'], negation: true }, + ]); + expect(run('not:type:foo kind:component,user')).toEqual([ + { key: 'type', parameters: ['foo'], negation: true }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, ]); expect(run('kind:component,user type:foo')).toEqual([ - { key: 'kind', parameters: ['component', 'user'] }, - { key: 'type', parameters: ['foo'] }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, + { key: 'type', parameters: ['foo'], negation: false }, ]); expect(run('with:multiple:colons')).toEqual([ - { key: 'with', parameters: ['multiple:colons'] }, + { key: 'with', parameters: ['multiple:colons'], negation: false }, ]); }); diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts index 4f809690f7..9775f16e51 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts @@ -27,6 +27,7 @@ const rootMatcherFactories: Record< ( parameters: string[], onParseError: (error: Error) => void, + negation?: boolean, ) => EntityMatcherFn > = { kind: createKindMatcher, @@ -60,9 +61,9 @@ export function parseFilterExpression(expression: string): { const parts = splitFilterExpression(expression, e => expressionParseErrors.push(e), ); - const matchers = parts.flatMap(part => { const factory = rootMatcherFactories[part.key]; + const negation = part.negation; if (!factory) { const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`); expressionParseErrors.push( @@ -76,7 +77,8 @@ export function parseFilterExpression(expression: string): { const matcher = factory(part.parameters, e => expressionParseErrors.push(e), ); - return [matcher]; + + return [negation ? (entity: Entity) => !matcher(entity) : matcher]; }); const filterFn = (entity: Entity) => @@ -97,16 +99,20 @@ export function parseFilterExpression(expression: string): { export function splitFilterExpression( expression: string, onParseError: (error: Error) => void, -): Array<{ key: string; parameters: string[] }> { +): Array<{ key: string; parameters: string[]; negation: boolean }> { const words = expression .split(' ') .map(w => w.trim()) .filter(Boolean); - const result = new Array<{ key: string; parameters: string[] }>(); + const result = new Array<{ + key: string; + parameters: string[]; + negation: boolean; + }>(); for (const word of words) { - const match = word.match(/^([^:]+):(.+)$/); + const match = word.match(/^(not:)?([^:]+):(.+)$/); if (!match) { onParseError( new InputError( @@ -115,11 +121,10 @@ export function splitFilterExpression( ); continue; } - - const key = match[1]; - const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas - - result.push({ key, parameters }); + const key = match[2]; + const parameters = match[3].split(',').filter(Boolean); // silently ignore double commas + const negation = Boolean(match[1]); + result.push({ key, parameters, negation }); } return result; diff --git a/plugins/scaffolder-backend-module-azure/package.json b/plugins/scaffolder-backend-module-azure/package.json index 25b8040455..38c0a13028 100644 --- a/plugins/scaffolder-backend-module-azure/package.json +++ b/plugins/scaffolder-backend-module-azure/package.json @@ -47,7 +47,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", - "azure-devops-node-api": "^12.0.0", + "azure-devops-node-api": "^14.0.0", "yaml": "^2.0.0" }, "devDependencies": { diff --git a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md index d103743b91..546a2d3834 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md @@ -44,6 +44,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { description?: string | undefined; targetBranch?: string | undefined; sourceBranch: string; + reviewers?: string[] | undefined; token?: string | undefined; gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.test.ts index b83ba2be55..9cb27eb4be 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.test.ts @@ -366,7 +366,7 @@ describe('publish:bitbucketServer:pull-request', () => { }); it(`should ${examples[4].description}`, async () => { - expect.assertions(7); + expect.assertions(8); server.use( rest.get( 'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches', @@ -389,6 +389,7 @@ describe('publish:bitbucketServer:pull-request', () => { toRef: { displayId: string }; fromRef: { displayId: string }; description: string; + reviewers: [{ user: { name: string } }]; }; expect(requestBody.title).toBe('My pull request'); expect(requestBody.fromRef.displayId).toBe('my-feature-branch'); @@ -396,6 +397,10 @@ describe('publish:bitbucketServer:pull-request', () => { expect(requestBody.description).toBe( 'This is a detailed description of my pull request', ); + expect(requestBody.reviewers).toEqual([ + { user: { name: 'reviewer1' } }, + { user: { name: 'reviewer2' } }, + ]); expect(req.headers.get('Authorization')).toBe( `Bearer ${yaml.parse(examples[4].example).steps[0].input.token}`, ); diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.ts index 77287e64e1..2859a2b051 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.examples.ts @@ -107,6 +107,7 @@ export const examples: TemplateExample[] = [ sourceBranch: 'my-feature-branch', targetBranch: 'development', description: 'This is a detailed description of my pull request', + reviewers: ['reviewer1', 'reviewer2'], token: 'my-auth-token', gitAuthorName: 'test-user', gitAuthorEmail: 'test-user@sample.com', diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts index fe79935a1d..1533393a52 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts @@ -54,6 +54,7 @@ const createPullRequest = async (opts: { latestChangeset: string; isDefault: boolean; }; + reviewers?: string[]; authorization: string; apiBaseUrl: string; }) => { @@ -64,6 +65,7 @@ const createPullRequest = async (opts: { description, toRef, fromRef, + reviewers, authorization, apiBaseUrl, } = opts; @@ -80,6 +82,7 @@ const createPullRequest = async (opts: { locked: true, toRef: toRef, fromRef: fromRef, + reviewers: reviewers?.map(reviewer => ({ user: { name: reviewer } })), }), headers: { Authorization: authorization, @@ -257,6 +260,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { description?: string; targetBranch?: string; sourceBranch: string; + reviewers?: string[]; token?: string; gitAuthorName?: string; gitAuthorEmail?: string; @@ -292,6 +296,15 @@ export function createPublishBitbucketServerPullRequestAction(options: { type: 'string', description: 'Branch of repository to copy changes from', }, + reviewers: { + title: 'Pull Request Reviewers', + type: 'array', + items: { + type: 'string', + }, + description: + 'The usernames of reviewers that will be added to the pull request', + }, token: { title: 'Authorization Token', type: 'string', @@ -327,6 +340,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { description, targetBranch, sourceBranch, + reviewers, gitAuthorName, gitAuthorEmail, } = ctx.input; @@ -480,6 +494,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { description, toRef, fromRef, + reviewers, authorization, apiBaseUrl, }); diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 86964b7623..547c61768f 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -49,6 +49,41 @@ export function createGithubAutolinksAction(options: { JsonObject >; +// @public +export function createGithubBranchProtectionAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction< + { + repoUrl: string; + branch?: string | undefined; + enforceAdmins?: boolean | undefined; + requiredApprovingReviewCount?: number | undefined; + requireCodeOwnerReviews?: boolean | undefined; + dismissStaleReviews?: boolean | undefined; + bypassPullRequestAllowances?: + | { + users?: string[]; + teams?: string[]; + apps?: string[]; + } + | undefined; + restrictions?: + | { + users: string[]; + teams: string[]; + apps?: string[]; + } + | undefined; + requiredStatusCheckContexts?: string[] | undefined; + requireBranchesToBeUpToDate?: boolean | undefined; + requiredConversationResolution?: boolean | undefined; + requireLastPushApproval?: boolean | undefined; + requiredCommitSigning?: boolean | undefined; + token?: string | undefined; + }, + JsonObject +>; + // @public export function createGithubDeployKeyAction(options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts new file mode 100644 index 0000000000..5443d7304c --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import yaml from 'yaml'; +import { examples } from './githubBranchProtection.examples'; +import { createGithubBranchProtectionAction } from './githubBranchProtection'; + +const mockOctokit = { + rest: { + repos: { + createCommitSignatureProtection: jest.fn(), + get: jest.fn(), + updateBranchProtection: jest.fn(), + }, + }, +}; +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('github:branch-protection:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + let action: TemplateAction; + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'master', + }, + }); + + action = createGithubBranchProtectionAction({ + integrations, + }); + }); + + it('should create branch protection for the default branch with default params', async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should create branch protection for the specified branch', async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'my-awesome-branch', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should create branch protection with params and require commit signing', async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'master', + required_status_checks: { + strict: true, + contexts: ['test'], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: true, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_conversation_resolution: true, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + branch: 'master', + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts new file mode 100644 index 0000000000..4d0eb253ef --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: `GitHub Branch Protection for repository's default branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }, + ], + }), + }, + { + description: `GitHub Branch Protection for a specific branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + branch: 'my-awesome-branch', + }, + }, + ], + }), + }, + { + description: `GitHub Branch Protection and required commit signing on default branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + requireCodeOwnerReviews: true, + requiredStatusCheckContexts: ['test'], + dismissStaleReviews: true, + requireLastPushApproval: true, + requiredConversationResolution: true, + requiredCommitSigning: true, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts new file mode 100644 index 0000000000..d4eac30b81 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { createGithubBranchProtectionAction } from './githubBranchProtection'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; + +const mockOctokit = { + rest: { + repos: { + createCommitSignatureProtection: jest.fn(), + get: jest.fn(), + updateBranchProtection: jest.fn(), + }, + }, +}; + +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('github:branch-protection:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + let action: TemplateAction; + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repository&owner=owner', + name: 'envname', + }, + }); + + beforeEach(() => { + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'master', + }, + }); + + action = createGithubBranchProtectionAction({ + integrations, + }); + }); + + afterEach(jest.resetAllMocks); + + it('should work with default params', async () => { + await action.handler(mockContext); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should require commit signing on default branch', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + requiredCommitSigning: true, + }, + }); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repository', + branch: 'master', + }); + }); + + it('should work with all params supplied', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + branch: 'branch', + enforceAdmins: false, + requiredApprovingReviewCount: 2, + requireCodeOwnerReviews: true, + dismissStaleReviews: true, + bypassPullRequestAllowances: { + users: ['user1'], + teams: ['team1'], + apps: ['app1'], + }, + restrictions: { + users: ['user2'], + teams: ['team2'], + apps: ['app2'], + }, + requiredStatusCheckContexts: ['context1', 'context2'], + requireBranchesToBeUpToDate: false, + requiredConversationResolution: true, + requireLastPushApproval: true, + requiredCommitSigning: true, + }, + }); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'branch', + required_status_checks: { + strict: false, + contexts: ['context1', 'context2'], + }, + restrictions: { + users: ['user2'], + teams: ['team2'], + apps: ['app2'], + }, + enforce_admins: false, + required_pull_request_reviews: { + required_approving_review_count: 2, + require_code_owner_reviews: true, + bypass_pull_request_allowances: { + users: ['user1'], + teams: ['team1'], + apps: ['app1'], + }, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_conversation_resolution: true, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repository', + branch: 'branch', + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts new file mode 100644 index 0000000000..4ab847c98e --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { InputError } from '@backstage/errors'; +import { + createTemplateAction, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { examples } from './githubBranchProtection.examples'; +import * as inputProps from './inputProperties'; +import { getOctokitOptions } from './helpers'; +import { Octokit } from 'octokit'; +import { enableBranchProtectionOnDefaultRepoBranch } from './gitHelpers'; + +/** + * Creates an `github:branch-protection:create` Scaffolder action that configured Branch Protection in a Github Repository. + * + * @public + */ +export function createGithubBranchProtectionAction(options: { + integrations: ScmIntegrationRegistry; +}) { + const { integrations } = options; + + return createTemplateAction<{ + repoUrl: string; + branch?: string; + enforceAdmins?: boolean; + requiredApprovingReviewCount?: number; + requireCodeOwnerReviews?: boolean; + dismissStaleReviews?: boolean; + bypassPullRequestAllowances?: + | { + users?: string[]; + teams?: string[]; + apps?: string[]; + } + | undefined; + restrictions?: + | { + users: string[]; + teams: string[]; + apps?: string[]; + } + | undefined; + requiredStatusCheckContexts?: string[]; + requireBranchesToBeUpToDate?: boolean; + requiredConversationResolution?: boolean; + requireLastPushApproval?: boolean; + requiredCommitSigning?: boolean; + token?: string; + }>({ + id: 'github:branch-protection:create', + description: 'Configures Branch Protection', + examples, + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: inputProps.repoUrl, + branch: { + title: 'Branch name', + description: `The branch to protect. Defaults to the repository's default branch`, + type: 'string', + }, + enforceAdmins: inputProps.protectEnforceAdmins, + requiredApprovingReviewCount: inputProps.requiredApprovingReviewCount, + requireCodeOwnerReviews: inputProps.requireCodeOwnerReviews, + dismissStaleReviews: inputProps.dismissStaleReviews, + bypassPullRequestAllowances: inputProps.bypassPullRequestAllowances, + restrictions: inputProps.restrictions, + requiredStatusCheckContexts: inputProps.requiredStatusCheckContexts, + requireBranchesToBeUpToDate: inputProps.requireBranchesToBeUpToDate, + requiredConversationResolution: + inputProps.requiredConversationResolution, + requireLastPushApproval: inputProps.requireLastPushApproval, + requiredCommitSigning: inputProps.requiredCommitSigning, + token: inputProps.token, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + branch, + enforceAdmins = true, + requiredApprovingReviewCount = 1, + requireCodeOwnerReviews = false, + dismissStaleReviews = false, + bypassPullRequestAllowances, + restrictions, + requiredStatusCheckContexts = [], + requireBranchesToBeUpToDate = true, + requiredConversationResolution = false, + requireLastPushApproval = false, + requiredCommitSigning = false, + token: providedToken, + } = ctx.input; + + const octokitOptions = await getOctokitOptions({ + integrations, + token: providedToken, + repoUrl: repoUrl, + }); + const client = new Octokit(octokitOptions); + + const { owner, repo } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError(`No owner provided for repo ${repoUrl}`); + } + + const repository = await client.rest.repos.get({ + owner: owner, + repo: repo, + }); + + await enableBranchProtectionOnDefaultRepoBranch({ + repoName: repo, + client, + owner, + logger: ctx.logger, + requireCodeOwnerReviews, + bypassPullRequestAllowances, + requiredApprovingReviewCount, + restrictions, + requiredStatusCheckContexts, + requireBranchesToBeUpToDate, + requiredConversationResolution, + requireLastPushApproval, + defaultBranch: branch ?? repository.data.default_branch, + enforceAdmins, + dismissStaleReviews, + requiredCommitSigning, + }); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-github/src/actions/index.ts b/plugins/scaffolder-backend-module-github/src/actions/index.ts index 6f37678099..c758415e1d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/index.ts @@ -28,5 +28,6 @@ export { export { createPublishGithubAction } from './github'; export { createGithubAutolinksAction } from './githubAutolinks'; export { createGithubPagesEnableAction } from './githubPagesEnable'; +export { createGithubBranchProtectionAction } from './githubBranchProtection'; export { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 03835cf411..f837ad0456 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -30,6 +30,7 @@ import { createPublishGithubAction, createPublishGithubPullRequestAction, createGithubPagesEnableAction, + createGithubBranchProtectionAction, } from './actions'; import { DefaultGithubCredentialsProvider, @@ -102,6 +103,9 @@ export const githubModule = createBackendModule({ integrations, githubCredentialsProvider, }), + createGithubBranchProtectionAction({ + integrations, + }), ); }, }); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx index b9825bc056..69fcfd37bf 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx @@ -29,7 +29,7 @@ import { } from '../../../routes'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; -import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemAccess'; +import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemStore'; import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate'; export function TemplateIntroPage() { diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.test.ts b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.test.ts new file mode 100644 index 0000000000..2b02785270 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { act, renderHook, waitFor } from '@testing-library/react'; +import { useTemplateDirectory } from './useTemplateDirectory'; +import { + createExampleTemplate, + TemplateDirectoryAccess, + WebFileSystemStore, +} from '../../../lib/filesystem'; +import { + IterableDirectoryHandle, + WebFileSystemAccess, +} from '../../../lib/filesystem/WebFileSystemAccess'; + +jest.mock('../../../lib/filesystem/createExampleTemplate'); + +describe('useTemplateDirectory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined when there is no existing directory in the file system store', async () => { + jest.spyOn(WebFileSystemStore, 'getDirectory').mockResolvedValue(undefined); + + const { result } = renderHook(() => useTemplateDirectory()); + + expect(result.current.directory).toBeUndefined(); + expect(result.current.loading).toBeTruthy(); + expect(result.current.error).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toBeFalsy()); + + expect(result.current.directory).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + }); + + it('should return an access when there is existing directory in the file system store', async () => { + const handle = {} as IterableDirectoryHandle; + + jest.spyOn(WebFileSystemStore, 'getDirectory').mockResolvedValue(handle); + + const { result } = renderHook(() => useTemplateDirectory()); + + await waitFor(() => expect(result.current.loading).toBeFalsy()); + + expect(result.current.directory).toMatchObject({ handle }); + expect(result.current.error).toBeUndefined(); + }); + + it('should handle opening a directory', async () => { + const handle = {}; + jest + .spyOn(WebFileSystemStore, 'getDirectory') + .mockResolvedValue(handle as IterableDirectoryHandle); + const setDirectory = jest + .spyOn(WebFileSystemStore, 'setDirectory') + .mockResolvedValue(undefined); + const requestDirectoryAccess = jest + .spyOn(WebFileSystemAccess, 'requestDirectoryAccess') + .mockResolvedValue(handle as TemplateDirectoryAccess); + + const { result } = renderHook(() => useTemplateDirectory()); + + expect(result.current.directory).toBeUndefined(); + + await act(async () => { + result.current.handleOpenDirectory(); + }); + + expect(requestDirectoryAccess).toHaveBeenCalled(); + expect(setDirectory).toHaveBeenCalledWith(handle); + + await waitFor(() => expect(result.current.directory).toBeDefined()); + }); + + it('should handle creating a directory', async () => { + const handle = {}; + (createExampleTemplate as jest.Mock).mockResolvedValue(handle); + jest + .spyOn(WebFileSystemStore, 'getDirectory') + .mockResolvedValue(handle as IterableDirectoryHandle); + const setDirectory = jest + .spyOn(WebFileSystemStore, 'setDirectory') + .mockResolvedValue(undefined); + const requestDirectoryAccess = jest + .spyOn(WebFileSystemAccess, 'requestDirectoryAccess') + .mockResolvedValue(handle as TemplateDirectoryAccess); + + const { result } = renderHook(() => useTemplateDirectory()); + + await act(async () => { + result.current.handleCreateDirectory(); + }); + + expect(requestDirectoryAccess).toHaveBeenCalled(); + expect(setDirectory).toHaveBeenCalledWith(handle); + expect(createExampleTemplate).toHaveBeenCalledWith(handle); + }); + + it('should handle closing a directory', async () => { + jest.spyOn(WebFileSystemStore, 'getDirectory').mockResolvedValue(undefined); + + const setDirectory = jest + .spyOn(WebFileSystemStore, 'setDirectory') + .mockResolvedValue(undefined); + + const { result } = renderHook(() => useTemplateDirectory()); + + expect(setDirectory).not.toHaveBeenCalled(); + expect(result.current.directory).toBeUndefined(); + + await act(async () => { + result.current.handleCloseDirectory(); + }); + + expect(setDirectory).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(result.current.directory).toBeUndefined()); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.ts b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.ts index 812a21e437..47b05db872 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.ts +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/useTemplateDirectory.ts @@ -18,11 +18,11 @@ import { useCallback } from 'react'; import useAsyncRetry from 'react-use/esm/useAsyncRetry'; import { - WebFileSystemAccess, WebFileSystemStore, + WebFileSystemAccess, WebDirectoryAccess, + createExampleTemplate, } from '../../../lib/filesystem'; -import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate'; export function useTemplateDirectory(): { directory?: WebDirectoryAccess; diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts index cfb0a6c771..c304e7b394 100644 --- a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { get, set } from 'idb-keyval'; import { TemplateDirectoryAccess, TemplateFileAccess } from './types'; type WritableFileHandle = FileSystemFileHandle & { @@ -25,7 +24,7 @@ type WritableFileHandle = FileSystemFileHandle & { }; // A nicer type than the one from the TS lib -interface IterableDirectoryHandle extends FileSystemDirectoryHandle { +export interface IterableDirectoryHandle extends FileSystemDirectoryHandle { values(): AsyncIterable< | ({ kind: 'file' } & WritableFileHandle) | ({ kind: 'directory' } & IterableDirectoryHandle) @@ -124,16 +123,3 @@ export class WebFileSystemAccess { private constructor() {} } - -export class WebFileSystemStore { - private static readonly key = 'scalfolder-template-editor-directory'; - - static async getDirectory(): Promise { - const directory = await get(WebFileSystemStore.key); - return directory.handle; - } - - static async setDirectory(directory: TemplateDirectoryAccess | undefined) { - return set(WebFileSystemStore.key, directory); - } -} diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemStore.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemStore.ts new file mode 100644 index 0000000000..1ea90dcf76 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemStore.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { get, set } from 'idb-keyval'; +import { TemplateDirectoryAccess } from './types'; +import { IterableDirectoryHandle } from './WebFileSystemAccess'; + +export class WebFileSystemStore { + private static readonly key = 'scalfolder-template-editor-directory'; + + static async getDirectory(): Promise { + const directory = await get(WebFileSystemStore.key); + return directory.handle; + } + + static async setDirectory(directory: TemplateDirectoryAccess | undefined) { + return set(WebFileSystemStore.key, directory); + } +} diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts index 4f5c9ff08c..4f44ab1ccd 100644 --- a/plugins/scaffolder/src/lib/filesystem/index.ts +++ b/plugins/scaffolder/src/lib/filesystem/index.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -export type { TemplateFileAccess, TemplateDirectoryAccess } from './types'; export { blobToBase64 } from './helpers'; -export { - WebDirectoryAccess, - WebFileSystemAccess, - WebFileSystemStore, -} from './WebFileSystemAccess'; +export { createExampleTemplate } from './createExampleTemplate'; +export type { TemplateFileAccess, TemplateDirectoryAccess } from './types'; +export { WebFileSystemStore } from './WebFileSystemStore'; +export { WebDirectoryAccess, WebFileSystemAccess } from './WebFileSystemAccess'; diff --git a/yarn.lock b/yarn.lock index 428619bfc8..1d74325b53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7200,7 +7200,7 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" - azure-devops-node-api: ^12.0.0 + azure-devops-node-api: ^14.0.0 yaml: ^2.0.0 languageName: unknown linkType: soft @@ -8756,8 +8756,8 @@ __metadata: linkType: hard "@changesets/cli@npm:^2.14.0": - version: 2.27.8 - resolution: "@changesets/cli@npm:2.27.8" + version: 2.27.9 + resolution: "@changesets/cli@npm:2.27.9" dependencies: "@changesets/apply-release-plan": ^7.0.5 "@changesets/assemble-release-plan": ^6.0.4 @@ -8774,14 +8774,12 @@ __metadata: "@changesets/types": ^6.0.0 "@changesets/write": ^0.3.2 "@manypkg/get-packages": ^1.1.3 - "@types/semver": ^7.5.0 ansi-colors: ^4.1.3 ci-info: ^3.7.0 enquirer: ^2.3.0 external-editor: ^3.1.0 fs-extra: ^7.0.1 mri: ^1.2.0 - outdent: ^0.5.0 p-limit: ^2.2.0 package-manager-detector: ^0.2.0 picocolors: ^1.1.0 @@ -8791,7 +8789,7 @@ __metadata: term-size: ^2.1.0 bin: changeset: bin.js - checksum: b58386716b337976d5797debd4a418fb257bec1e6c9932b99eac7725dd5a76fceff32691c625f897fd4baa78aa2bfba9747fbacf7d8193197f9246b36d31c013 + checksum: 4bd36c152f9f93716b001f3ed849717588d2a9eb97f058e86f95ba6a43d8e4311073174251150aabb96f0a1ab5f8ab5ee6a32f85fc9248363f92b3826227cb9d languageName: node linkType: hard @@ -18074,9 +18072,9 @@ __metadata: linkType: hard "@types/lodash@npm:^4.14.151": - version: 4.17.9 - resolution: "@types/lodash@npm:4.17.9" - checksum: 6d1bf3e77f0a54d97532755a74260d402d8972259c5451b74612c16cb983b73e0760e5bfe4f9e68ab15051511c867812b40715a01f9805afe6bc36c7dd676378 + version: 4.17.10 + resolution: "@types/lodash@npm:4.17.10" + checksum: 4600f2f25270c8fee6953e363d318149a5f0f1b1bb820aa2f42d7ada6e4f7de31848bb5ffc2c687b40bd73aa982167bdd6e6d8d456e72abe0c660ec77d1fa7e9 languageName: node linkType: hard @@ -21546,13 +21544,13 @@ __metadata: languageName: node linkType: hard -"azure-devops-node-api@npm:^12.0.0": - version: 12.5.0 - resolution: "azure-devops-node-api@npm:12.5.0" +"azure-devops-node-api@npm:^14.0.0": + version: 14.0.2 + resolution: "azure-devops-node-api@npm:14.0.2" dependencies: tunnel: 0.0.6 - typed-rest-client: ^1.8.4 - checksum: 7c2c3ae21eaf1bc3627ba4ea87bdac1085a3594eacf40eb6d7b11292f057988db38f718f4597733c6861d854c28bfe146bcf3964a13adddebe1085270bb63097 + typed-rest-client: ^2.0.1 + checksum: 26f39c7772d313befd49696fe2a1e91a8ffd75cfeb0c7f305a7102867f1090d299544361663b14e99bbebbf5855d1fc56ead0e4e32ee53bc83b487cdbe30d254 languageName: node linkType: hard @@ -24972,13 +24970,13 @@ __metadata: languageName: node linkType: hard -"des.js@npm:^1.0.0": - version: 1.0.1 - resolution: "des.js@npm:1.0.1" +"des.js@npm:^1.0.0, des.js@npm:^1.1.0": + version: 1.1.0 + resolution: "des.js@npm:1.1.0" dependencies: inherits: ^2.0.1 minimalistic-assert: ^1.0.0 - checksum: 1ec2eedd7ed6bd61dd5e0519fd4c96124e93bb22de8a9d211b02d63e5dd152824853d919bb2090f965cc0e3eb9c515950a9836b332020d810f9c71feb0fd7df4 + checksum: 0e9c1584b70d31e20f20a613fc9ef60fbc6a147dfec9e448a168794a4b97ac04d8dc47ea008f1fa93b0f8aaf7c1ead632a5e59ce1913a6079d2d244c9f5ebe33 languageName: node linkType: hard @@ -26929,7 +26927,7 @@ __metadata: "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 "@types/luxon": ^3.0.0 - azure-devops-node-api: ^12.0.0 + azure-devops-node-api: ^14.0.0 better-sqlite3: ^11.0.0 dockerode: ^4.0.0 example-app: "link:../app" @@ -31575,6 +31573,13 @@ __metadata: languageName: node linkType: hard +"js-md4@npm:^0.3.2": + version: 0.3.2 + resolution: "js-md4@npm:0.3.2" + checksum: aa7b7cbd738b105f86fc0fa50ce2a7a6b6b329ff3f713d1bd38b12c2a72929bab5a16e658a03830faa8d656356aa89b4f77f1fbf05ffa8d095bda6d831441c2d + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -35167,11 +35172,9 @@ __metadata: linkType: hard "node-mocks-http@npm:^1.0.0": - version: 1.16.0 - resolution: "node-mocks-http@npm:1.16.0" + version: 1.16.1 + resolution: "node-mocks-http@npm:1.16.1" dependencies: - "@types/express": ^4.17.21 - "@types/node": "*" accepts: ^1.3.7 content-disposition: ^0.5.3 depd: ^1.1.0 @@ -35182,7 +35185,15 @@ __metadata: parseurl: ^1.3.3 range-parser: ^1.2.0 type-is: ^1.6.18 - checksum: 21ccf1ecaaa6ee0f7c061a7063f59d59c9793a485a907c09c83ff73b12f205ef87537022a4ba8ad937d07454ee0450290093d4a66a4df21e7e8b3696d23e1a7d + peerDependencies: + "@types/express": ^4.17.21 || ^5.0.0 + "@types/node": "*" + peerDependenciesMeta: + "@types/express": + optional: true + "@types/node": + optional: true + checksum: 198030725eac236062eb3547a7d5cec2d6f2d44bf05efab0ea621e90f671d6966dca2873faaea9bcbb5dde92a222aff0610a01d453c2f76b0bc5dbdf4bd8057a languageName: node linkType: hard @@ -38048,7 +38059,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.9.1, qs@npm:^6.9.4": +"qs@npm:6.13.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.9.4": version: 6.13.0 resolution: "qs@npm:6.13.0" dependencies: @@ -43100,14 +43111,16 @@ __metadata: languageName: node linkType: hard -"typed-rest-client@npm:^1.8.4": - version: 1.8.4 - resolution: "typed-rest-client@npm:1.8.4" +"typed-rest-client@npm:^2.0.1": + version: 2.0.2 + resolution: "typed-rest-client@npm:2.0.2" dependencies: - qs: ^6.9.1 + des.js: ^1.1.0 + js-md4: ^0.3.2 + qs: ^6.10.3 tunnel: 0.0.6 underscore: ^1.12.1 - checksum: 238e2139724310fed39756ae734fbc811e803abaa7598720ad6cc5ab7f160d415d8107551422b186f6b2e08c5761c0e7b66ca47d60b2ccd952bb5e20d3b92f3c + checksum: f1d9a0ffd5b7266f01df9ea4eb08b8da04793cf6902d82b3c6a46d1328cdefe80bb0cfc8f96afa645077c234ec218d8b16f642da35c1e2f1adf3d61a5904d8d1 languageName: node linkType: hard