Merge branch 'master' into feat/26838

This commit is contained in:
Łukasz Jernaś
2024-10-07 09:15:24 +02:00
38 changed files with 1214 additions and 151 deletions
+20
View File
@@ -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
```
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Adding negation keyword for entity filtering
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch
---
Add `reviewers` input parameter to `publish:bitbucketServer:pull-request`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-azure': patch
---
Updated dependency `azure-devops-node-api` to `^14.0.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Support `--max-warnings` flag for package linting
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Add tests for the `useTemplateDirectory` hook.
+9 -8
View File
@@ -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
+1 -1
View File
@@ -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",
+1
View File
@@ -237,6 +237,7 @@ Usage: backstage-cli package lint [options] [directories...]
Options:
--format <format>
--fix
--max-warnings <number>
-h, --help
```
+4
View File
@@ -156,6 +156,10 @@ export function registerScriptCommand(program: Command) {
'eslint-formatter-friendly',
)
.option('--fix', 'Attempt to automatically fix violations')
.option(
'--max-warnings <number>',
'Fail if more than this number of warnings (default: 0)',
)
.description('Lint a package')
.action(lazy(() => import('./lint').then(m => m.default)));
+11 -2
View File
@@ -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);
}
};
+1 -51
View File
@@ -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<string, any> = {};
(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<void> {
let packages = await PackageGraph.listTargetPackages();
+34 -12
View File
@@ -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<void> {
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
if (opts.since) {
@@ -54,22 +55,30 @@ export async function command(opts: OptionValues): Promise<void> {
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<void> {
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());
}
}
}
@@ -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<string, any> = {};
(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;
};
}
@@ -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,
},
]);
});
});
@@ -36,10 +36,9 @@ export async function readFrontendConfig(options: {
}): Promise<AppConfig[]> {
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 [];
}
@@ -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 },
]);
});
@@ -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;
@@ -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": {
@@ -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;
@@ -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}`,
);
@@ -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',
@@ -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,
});
@@ -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;
@@ -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<any>;
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',
});
});
});
@@ -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,
},
},
],
}),
},
];
@@ -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<any>;
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',
});
});
});
@@ -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,
});
},
});
}
@@ -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';
@@ -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,
}),
);
},
});
@@ -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() {
@@ -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());
});
});
@@ -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;
@@ -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<IterableDirectoryHandle | undefined> {
const directory = await get(WebFileSystemStore.key);
return directory.handle;
}
static async setDirectory(directory: TemplateDirectoryAccess | undefined) {
return set(WebFileSystemStore.key, directory);
}
}
@@ -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<IterableDirectoryHandle | undefined> {
const directory = await get(WebFileSystemStore.key);
return directory.handle;
}
static async setDirectory(directory: TemplateDirectoryAccess | undefined) {
return set(WebFileSystemStore.key, directory);
}
}
@@ -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';
+43 -30
View File
@@ -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