Merge branch 'master' into mob/use-entity-list-provider-in-scaffolder-page

This commit is contained in:
Tim Hansen
2021-07-06 13:28:57 -06:00
124 changed files with 2218 additions and 323 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
+1 -1
View File
@@ -44,7 +44,7 @@
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-session": "^1.17.1",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"got": "^11.5.2",
"helmet": "^4.0.0",
"jose": "^1.27.1",
@@ -16,6 +16,15 @@ import { SearchEntry } from 'ldapjs';
import { SearchOptions } from 'ldapjs';
import { UserEntity } from '@backstage/catalog-model';
// @public (undocumented)
export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise<GroupEntity | undefined>;
// @public (undocumented)
export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise<UserEntity | undefined>;
// @public
export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise<GroupEntity | undefined>;
// @public
export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn";
@@ -40,14 +49,18 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
constructor(options: {
providers: LdapProviderConfig[];
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
});
// (undocumented)
static fromConfig(config: Config, options: {
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
}): LdapOrgReaderProcessor;
// (undocumented)
readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise<boolean>;
}
}
// @public
export type LdapProviderConfig = {
@@ -57,15 +70,25 @@ export type LdapProviderConfig = {
groups: GroupConfig;
};
// @public
export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void;
// @public
export function readLdapConfig(config: Config): LdapProviderConfig[];
// @public
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
logger: Logger;
}): Promise<{
users: UserEntity[];
groups: GroupEntity[];
}>;
// @public
export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise<UserEntity | undefined>;
// (No @packageDocumentation comment for this package)
@@ -15,6 +15,7 @@
*/
export { LdapClient } from './client';
export { mapStringAttr } from './util';
export { readLdapConfig } from './config';
export type { LdapProviderConfig } from './config';
export {
@@ -22,4 +23,9 @@ export {
LDAP_RDN_ANNOTATION,
LDAP_UUID_ANNOTATION,
} from './constants';
export { readLdapOrg } from './read';
export {
defaultGroupTransformer,
defaultUserTransformer,
readLdapOrg,
} from './read';
export type { GroupTransformer, UserTransformer } from './types';
@@ -26,74 +26,97 @@ import {
LDAP_UUID_ANNOTATION,
} from './constants';
import { LdapVendor } from './vendors';
import { Logger } from 'winston';
import { GroupTransformer, UserTransformer } from './types';
import { mapStringAttr } from './util';
export async function defaultUserTransformer(
vendor: LdapVendor,
config: UserConfig,
entry: SearchEntry,
): Promise<UserEntity | undefined> {
const { set, map } = config;
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: '',
annotations: {},
},
spec: {
profile: {},
memberOf: [],
},
};
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
return entity;
}
/**
* Reads users out of an LDAP provider.
*
* @param client The LDAP client
* @param config The user data configuration
* @param opts
*/
export async function readLdapUsers(
client: LdapClient,
config: UserConfig,
opts?: { transformer?: UserTransformer },
): Promise<{
users: UserEntity[]; // With all relations empty
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
}> {
const { dn, options, set, map } = config;
const { dn, options, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
const entities: UserEntity[] = [];
const userMemberOf: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: '',
annotations: {},
},
spec: {
profile: {},
memberOf: [],
},
};
const transformer = opts?.transformer ?? defaultUserTransformer;
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
const entries = await client.search(dn, options);
for (const user of entries) {
const entity = await transformer(vendor, config, user);
if (!entity) {
continue;
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
ensureItems(userMemberOf, myDn, vs);
});
@@ -103,82 +126,103 @@ export async function readLdapUsers(
return { users: entities, userMemberOf };
}
export async function defaultGroupTransformer(
vendor: LdapVendor,
config: GroupConfig,
entry: SearchEntry,
): Promise<GroupEntity | undefined> {
const { set, map } = config;
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: '',
annotations: {},
},
spec: {
type: 'unknown',
profile: {},
children: [],
},
};
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.type, v => {
entity.spec.type = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
return entity;
}
/**
* Reads groups out of an LDAP provider.
*
* @param client The LDAP client
* @param config The group data configuration
* @param opts
*/
export async function readLdapGroups(
client: LdapClient,
config: GroupConfig,
opts?: {
transformer?: GroupTransformer;
},
): Promise<{
groups: GroupEntity[]; // With all relations empty
groupMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
}> {
const { dn, options, set, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
const groups: GroupEntity[] = [];
const groupMemberOf: Map<string, Set<string>> = new Map();
const groupMember: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: '',
annotations: {},
},
spec: {
type: 'unknown',
profile: {},
children: [],
},
};
const { dn, map, options } = config;
const vendor = await client.getVendor();
if (set) {
for (const [path, value] of Object.entries(set)) {
lodashSet(entity, path, value);
}
const transformer = opts?.transformer ?? defaultGroupTransformer;
const entries = await client.search(dn, options);
for (const group of entries) {
const entity = await transformer(vendor, config, group);
if (!entity) {
continue;
}
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(entry, vendor, map.type, v => {
entity.spec.type = v;
});
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => {
ensureItems(groupMemberOf, myDn, vs);
});
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
mapReferencesAttr(group, vendor, map.members, (myDn, vs) => {
ensureItems(groupMember, myDn, vs);
});
@@ -199,22 +243,30 @@ export async function readLdapGroups(
* with all relations etc filled in.
*
* @param client The LDAP client
* @param logger A logger instance
* @param userConfig The user data configuration
* @param groupConfig The group data configuration
* @param options
*/
export async function readLdapOrg(
client: LdapClient,
userConfig: UserConfig,
groupConfig: GroupConfig,
options: {
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
logger: Logger;
},
): Promise<{
users: UserEntity[];
groups: GroupEntity[];
}> {
const { users, userMemberOf } = await readLdapUsers(client, userConfig);
const { users, userMemberOf } = await readLdapUsers(client, userConfig, {
transformer: options?.userTransformer,
});
const { groups, groupMemberOf, groupMember } = await readLdapGroups(
client,
groupConfig,
{ transformer: options?.groupTransformer },
);
resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember);
@@ -228,21 +280,6 @@ export async function readLdapOrg(
// Helpers
//
// Maps a single-valued attribute to a consumer
function mapStringAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (value: string) => void,
) {
if (attributeName) {
const values = vendor.decodeStringAttribute(entry, attributeName);
if (values && values.length === 1) {
setter(values[0]);
}
}
}
// Maps a multi-valued attribute of references to other objects, to a consumer
function mapReferencesAttr(
entry: SearchEntry,
@@ -0,0 +1,47 @@
/*
* Copyright 2021 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 { GroupEntity, UserEntity } from '@backstage/catalog-model';
import { SearchEntry } from 'ldapjs';
import { LdapVendor } from './vendors';
import { GroupConfig, UserConfig } from './config';
/**
* Customize the ingested User entity
*
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
* @param config The User specific config used by the default transformer.
* @param user The found LDAP entry in its source format. This is the entry that you want to transform
* @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog
*/
export type UserTransformer = (
vendor: LdapVendor,
config: UserConfig,
user: SearchEntry,
) => Promise<UserEntity | undefined>;
/**
* Customize the ingested Group entity
*
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
* @param config The Group specific config used by the default transformer.
* @param group The found LDAP entry in its source format. This is the entry that you want to transform
* @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog
*/
export type GroupTransformer = (
vendor: LdapVendor,
config: GroupConfig,
group: SearchEntry,
) => Promise<GroupEntity | undefined>;
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Error as LDAPError } from 'ldapjs';
import { Error as LDAPError, SearchEntry } from 'ldapjs';
import { LdapVendor } from './vendors';
/**
* Builds a string form of an LDAP Error structure.
@@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs';
export function errorString(error: LDAPError) {
return `${error.code} ${error.name}: ${error.message}`;
}
/**
* Maps a single-valued attribute to a consumer
*
* @param entry The LDAP source entry
* @param vendor The LDAP vendor
* @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored.
* @param setter The function to be called with the decoded attribute from the source entry
*/
export function mapStringAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (value: string) => void,
) {
if (attributeName) {
const values = vendor.decodeStringAttribute(entry, attributeName);
if (values && values.length === 1) {
setter(values[0]);
}
}
}
@@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import {
GroupTransformer,
LdapClient,
LdapProviderConfig,
readLdapConfig,
readLdapOrg,
UserTransformer,
} from '../ldap';
import {
CatalogProcessor,
@@ -35,8 +37,17 @@ import {
export class LdapOrgReaderProcessor implements CatalogProcessor {
private readonly providers: LdapProviderConfig[];
private readonly logger: Logger;
private readonly groupTransformer?: GroupTransformer;
private readonly userTransformer?: UserTransformer;
static fromConfig(config: Config, options: { logger: Logger }) {
static fromConfig(
config: Config,
options: {
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
},
) {
const c = config.getOptionalConfig('catalog.processors.ldapOrg');
return new LdapOrgReaderProcessor({
...options,
@@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
});
}
constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) {
constructor(options: {
providers: LdapProviderConfig[];
logger: Logger;
groupTransformer?: GroupTransformer;
userTransformer?: UserTransformer;
}) {
this.providers = options.providers;
this.logger = options.logger;
this.groupTransformer = options.groupTransformer;
this.userTransformer = options.userTransformer;
}
async readLocation(
@@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
client,
provider.users,
provider.groups,
{
groupTransformer: this.groupTransformer,
userTransformer: this.userTransformer,
logger: this.logger,
},
);
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
+1 -1
View File
@@ -46,7 +46,7 @@
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fast-json-stable-stringify": "^2.1.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"git-url-parse": "^11.4.4",
"glob": "^7.1.6",
"knex": "^0.95.1",
@@ -94,12 +94,21 @@ export class PlaceholderProcessor implements CatalogProcessor {
return [data, false];
}
const read = async (url: string): Promise<Buffer> => {
if (this.options.reader.readUrl) {
const response = await this.options.reader.readUrl(url);
const buffer = await response.buffer();
return buffer;
}
return this.options.reader.read(url);
};
return [
await resolver({
key: resolverKey,
value: resolverValue,
baseUrl: location.target,
read: this.options.reader.read.bind(this.options.reader),
read,
}),
true,
];
@@ -101,7 +101,12 @@ export class UrlReaderProcessor implements CatalogProcessor {
return Promise.all(output);
}
// Otherwise do a plain read
// Otherwise do a plain read, prioritizing readUrl if available
if (this.options.reader.readUrl) {
const data = await this.options.reader.readUrl(location);
return [{ url: location, data: await data.buffer() }];
}
const data = await this.options.reader.read(location);
return [{ url: location, data }];
}
@@ -28,6 +28,12 @@ export async function readCodeOwners(
): Promise<string | undefined> {
const readOwnerLocation = async (path: string): Promise<string> => {
const url = `${sourceUrl}${path}`;
if (reader.readUrl) {
const data = await reader.readUrl(url);
const buffer = await data.buffer();
return buffer.toString();
}
const data = await reader.read(url);
return data.toString();
};
@@ -46,29 +46,19 @@ jest.doMock('@octokit/rest', () => {
return { Octokit };
});
// Mock the value to control which integrations are activated
jest.mock('./GitHub', () => ({
getGithubIntegrationConfig: jest.fn(),
}));
import {
GitHubIntegrationConfig,
ScmIntegrations,
} from '@backstage/integration';
import { ScmIntegrations } from '@backstage/integration';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { msw } from '@backstage/test-utils';
import { Octokit } from '@octokit/rest';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogImportClient } from './CatalogImportClient';
import { getGithubIntegrationConfig } from './GitHub';
import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api';
import { OAuthApi } from '@backstage/core-plugin-api';
const server = setupServer();
describe('CatalogImportClient', () => {
const server = setupServer();
msw.setupDefaultHandlers(server);
const mockBaseUrl = 'http://backstage:9191/api/catalog';
@@ -92,7 +82,19 @@ describe('CatalogImportClient', () => {
},
};
const scmIntegrationsApi = ScmIntegrations.fromConfig(new ConfigReader({}));
const scmIntegrationsApi = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'example.com' }],
gitlab: [
{
host: 'registered-but-not-github.com',
apiBaseUrl: 'https://registered-but-not-github.com',
},
],
},
}),
);
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntities: jest.fn(),
@@ -106,7 +108,6 @@ describe('CatalogImportClient', () => {
};
let catalogImportClient: CatalogImportClient;
let getGithubIntegrationConfigFn: jest.Mock;
beforeEach(() => {
catalogImportClient = new CatalogImportClient({
@@ -116,9 +117,6 @@ describe('CatalogImportClient', () => {
identityApi,
catalogApi,
});
getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock;
getGithubIntegrationConfigFn.mockReset();
});
describe('analyzeUrl', () => {
@@ -169,10 +167,22 @@ describe('CatalogImportClient', () => {
});
});
it('should ignore missing github integration', async () => {
it('should reject for integrations that are not github ones', async () => {
await expect(
catalogImportClient.analyzeUrl(
'https://github.com/backstage/backstage',
'https://registered-but-not-github.com/backstage/backstage',
),
).rejects.toThrow(
new Error(
'The registered-but-not-github.com integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?',
),
);
});
it('should reject when unable to match with any integration', async () => {
await expect(
catalogImportClient.analyzeUrl(
'https://not-registered-as-integration.com/foo/bar',
),
).rejects.toThrow(
new Error(
@@ -182,12 +192,6 @@ describe('CatalogImportClient', () => {
});
it('should find locations from github', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {} as GitHubIntegrationConfig,
});
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
data: {
total_count: 2,
@@ -244,12 +248,6 @@ describe('CatalogImportClient', () => {
});
it('should find repository from github', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {} as GitHubIntegrationConfig,
});
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
data: { total_count: 0, items: [] },
});
@@ -300,14 +298,6 @@ describe('CatalogImportClient', () => {
describe('submitPullRequest', () => {
it('should create GitHub pull request', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {
host: 'github.com',
} as GitHubIntegrationConfig,
});
await expect(
catalogImportClient.submitPullRequest({
repositoryUrl: 'https://github.com/backstage/backstage',
@@ -77,6 +77,12 @@ export class CatalogImportClient implements CatalogImportApi {
const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url);
if (!ghConfig) {
const other = this.scmIntegrationsApi.byUrl(url);
if (other) {
throw new Error(
`The ${other.title} integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?`,
);
}
throw new Error(
'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.',
);
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { makeStyles } from '@material-ui/core';
import { Grid } from '@material-ui/core';
import {
EntityKindPicker,
EntityLifecyclePicker,
@@ -39,18 +39,6 @@ import {
TableProps,
} from '@backstage/core-components';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
buttonSpacing: {
marginLeft: theme.spacing(2),
},
}));
export type CatalogPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<EntityRow>[];
@@ -61,30 +49,36 @@ export const CatalogPage = ({
initiallySelectedFilter = 'owned',
columns,
actions,
}: CatalogPageProps) => {
const styles = useStyles();
return (
<CatalogLayout>
<Content>
<ContentHeader title="Components">
<CreateComponentButton />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</div>
}: CatalogPageProps) => (
<CatalogLayout>
<Content>
<ContentHeader title="Components">
<CreateComponentButton />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<Grid container spacing={2}>
<EntityListProvider>
<Grid item sm={12} lg={2} alignContent="flex-start">
<Grid container>
<Grid item xs={12} sm={4} lg={12}>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
</Grid>
<Grid item xs={12} sm={4} lg={12}>
<UserListPicker initialFilter={initiallySelectedFilter} />
</Grid>
<Grid item xs={12} sm={4} lg={12}>
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</Grid>
</Grid>
</Grid>
<Grid item xs={12} sm={12} lg={10}>
<CatalogTable columns={columns} actions={actions} />
</EntityListProvider>
</div>
</Content>
</CatalogLayout>
);
};
</Grid>
</EntityListProvider>
</Grid>
</Content>
</CatalogLayout>
);
@@ -18,7 +18,6 @@ import { ExploreTool } from '@backstage/plugin-explore-react';
import { BackstageTheme } from '@backstage/theme';
import {
Box,
Button,
Card,
CardActions,
CardContent,
@@ -27,6 +26,7 @@ import {
makeStyles,
Typography,
} from '@material-ui/core';
import { Button } from '@backstage/core-components';
import classNames from 'classnames';
import React from 'react';
@@ -97,7 +97,7 @@ export const ToolCard = ({ card, objectFit }: Props) => {
)}
</CardContent>
<CardActions>
<Button color="primary" href={url} disabled={!url}>
<Button color="primary" to={url} disabled={!url}>
Explore
</Button>
</CardActions>
@@ -69,6 +69,7 @@ describe('Features', () => {
<a
class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-colorPrimary"
href="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
rel="noopener"
target="_blank"
to="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
>
+1 -1
View File
@@ -26,7 +26,7 @@
"@backstage/errors": "^0.1.1",
"@backstage/plugin-catalog-react": "^0.2.4",
"@backstage/theme": "^0.2.8",
"@date-io/luxon": "1.x",
"@date-io/luxon": "2.x",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+1 -1
View File
@@ -44,7 +44,7 @@
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"helmet": "^4.0.0",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
@@ -23,23 +23,30 @@ import {
EntityRefLinks,
getEntityRelations,
useEntity,
getEntityMetadataEditUrl,
} from '@backstage/plugin-catalog-react';
import {
Box,
Grid,
Link,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
IconButton,
} from '@material-ui/core';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
import EmailIcon from '@material-ui/icons/Email';
import GroupIcon from '@material-ui/icons/Group';
import EditIcon from '@material-ui/icons/Edit';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components';
import {
Avatar,
InfoCard,
InfoCardVariants,
Link,
} from '@backstage/core-components';
const CardTitle = ({ title }: { title: string }) => (
<Box display="flex" alignItems="center">
@@ -72,14 +79,31 @@ export const GroupProfileCard = ({
kind: 'group',
});
const entityMetadataEditUrl = getEntityMetadataEditUrl(group);
const displayName = profile?.displayName ?? name;
const emailHref = profile?.email ? `mailto:${profile.email}` : undefined;
const emailHref = profile?.email ? `mailto:${profile.email}` : '#';
const infoCardAction = entityMetadataEditUrl ? (
<IconButton
aria-label="Edit"
title="Edit Metadata"
component={Link}
to={entityMetadataEditUrl}
>
<EditIcon />
</IconButton>
) : (
<IconButton aria-label="Edit" disabled title="Edit Metadata">
<EditIcon />
</IconButton>
);
return (
<InfoCard
title={<CardTitle title={displayName} />}
subheader={description}
variant={variant}
action={infoCardAction}
>
<Grid container spacing={3}>
<Grid item xs={12} sm={2} xl={1}>
@@ -95,7 +119,7 @@ export const GroupProfileCard = ({
</Tooltip>
</ListItemIcon>
<ListItemText>
<Link href={emailHref}>{profile.email}</Link>
<Link to={emailHref}>{profile.email}</Link>
</ListItemText>
</ListItem>
)}
+14 -4
View File
@@ -8,11 +8,21 @@ There is also an easy way to trigger an alarm directly to the person who is curr
This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document.
This plugin provides:
## Features
- A list of incidents
- A way to trigger an alarm to the person on-call
- Information details about the person on-call
### View any open incidents
![PagerDuty plugin showing no incidents and the on-call rotation](doc/pd1.png)
### Email link, and view contact information for staff on call
![PagerDuty plugin showing on-call rotation contact information](doc/pd2.png)
### Trigger an incident for a service
![PagerDuty plugin popup modal for creating an incident](doc/pd3.png)
![PagerDuty plugin showing an active incident](doc/pd4.png)
## Setup instructions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+1 -1
View File
@@ -39,7 +39,7 @@
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"helmet": "^4.0.0",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
+6
View File
@@ -143,6 +143,12 @@ export function createFetchPlainAction(options: {
integrations: ScmIntegrations;
}): TemplateAction<any>;
// @public (undocumented)
export const createFilesystemDeleteAction: () => TemplateAction<any>;
// @public (undocumented)
export const createFilesystemRenameAction: () => TemplateAction<any>;
// @public (undocumented)
export function createLegacyActions(options: Options): TemplateAction<any>[];
+1 -1
View File
@@ -47,7 +47,7 @@
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"git-url-parse": "^11.4.4",
"globby": "^11.0.0",
"handlebars": "^4.7.6",
@@ -24,6 +24,10 @@ import {
} from './catalog';
import { createDebugLogAction } from './debug';
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
import {
createFilesystemDeleteAction,
createFilesystemRenameAction,
} from './filesystem';
import {
createPublishAzureAction,
createPublishBitbucketAction,
@@ -68,5 +72,7 @@ export const createBuiltinActions = (options: {
createDebugLogAction(),
createCatalogRegisterAction({ catalogClient, integrations }),
createCatalogWriteAction(),
createFilesystemDeleteAction(),
createFilesystemRenameAction(),
];
};
@@ -0,0 +1,127 @@
/*
* Copyright 2021 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 * as os from 'os';
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import { createFilesystemDeleteAction } from './delete';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import fs from 'fs-extra';
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const workspacePath = resolvePath(root, 'my-workspace');
describe('fs:delete', () => {
const action = createFilesystemDeleteAction();
const mockContext = {
input: {
files: ['unit-test-a.js', 'unit-test-b.js'],
},
workspacePath,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
mockFs({
[workspacePath]: {
'unit-test-a.js': 'hello',
'unit-test-b.js': 'world',
'a-folder': {
'unit-test-in-a-folder.js2': 'content',
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw an error when files is not an array', async () => {
await expect(
action.handler({
...mockContext,
input: { files: undefined },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: {} },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: '' },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: null },
}),
).rejects.toThrow(/files must be an Array/);
});
it('should throw when file name is not relative to the workspace', async () => {
await expect(
action.handler({
...mockContext,
input: { files: ['/foo/../../../index.js'] },
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
await expect(
action.handler({
...mockContext,
input: { files: ['../../../index.js'] },
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should call fs.rm with the correct values', async () => {
const files = ['unit-test-a.js', 'unit-test-b.js'];
files.forEach(file => {
const filePath = resolvePath(workspacePath, file);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(true);
});
await action.handler(mockContext);
files.forEach(file => {
const filePath = resolvePath(workspacePath, file);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(false);
});
});
});
@@ -0,0 +1,59 @@
/*
* Copyright 2021 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 { createTemplateAction } from '../../createTemplateAction';
import { InputError } from '@backstage/errors';
import { resolveSafeChildPath } from '@backstage/backend-common';
import fs from 'fs-extra';
export const createFilesystemDeleteAction = () => {
return createTemplateAction<{ files: string[] }>({
id: 'fs:delete',
description: 'Deletes files and directories from the workspace',
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
description: 'A list of files and directories that will be deleted',
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (!Array.isArray(ctx.input?.files)) {
throw new InputError('files must be an Array');
}
for (const file of ctx.input.files) {
const filepath = resolveSafeChildPath(ctx.workspacePath, file);
try {
await fs.remove(filepath);
ctx.logger.info(`File ${filepath} deleted successfully`);
} catch (err) {
ctx.logger.error(`Failed to delete file ${filepath}:`, err);
throw err;
}
}
},
});
};
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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.
*/
export { createFilesystemDeleteAction } from './delete';
export { createFilesystemRenameAction } from './rename';
@@ -0,0 +1,216 @@
/*
* Copyright 2021 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 * as os from 'os';
import mockFs from 'mock-fs';
import { resolve as resolvePath } from 'path';
import { createFilesystemRenameAction } from './rename';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import fs from 'fs-extra';
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const workspacePath = resolvePath(root, 'my-workspace');
describe('fs:rename', () => {
const action = createFilesystemRenameAction();
const mockInputFiles = [
{
from: 'unit-test-a.js',
to: 'new-a.js',
},
{
from: 'unit-test-b.js',
to: 'new-b.js',
},
{
from: 'a-folder',
to: 'brand-new-folder',
},
];
const mockContext = {
input: {
files: mockInputFiles,
},
workspacePath,
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
mockFs({
[workspacePath]: {
'unit-test-a.js': 'hello',
'unit-test-b.js': 'world',
'unit-test-c.js': 'i will be overwritten :-(',
'a-folder': {
'file.md': 'content',
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw an error when files is not an array', async () => {
await expect(
action.handler({
...mockContext,
input: { files: undefined },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: {} },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: '' },
}),
).rejects.toThrow(/files must be an Array/);
await expect(
action.handler({
...mockContext,
input: { files: null },
}),
).rejects.toThrow(/files must be an Array/);
});
it('should throw an error when files have missing from/to', async () => {
await expect(
action.handler({
...mockContext,
input: { files: ['old.md'] },
}),
).rejects.toThrow(/each file must have a from and to property/);
await expect(
action.handler({
...mockContext,
input: { files: [{ from: 'old.md' }] },
}),
).rejects.toThrow(/each file must have a from and to property/);
await expect(
action.handler({
...mockContext,
input: { files: [{ to: 'new.md' }] },
}),
).rejects.toThrow(/each file must have a from and to property/);
});
it('should throw when file name is not relative to the workspace', async () => {
await expect(
action.handler({
...mockContext,
input: { files: [{ from: 'index.js', to: '/core/../../../index.js' }] },
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
await expect(
action.handler({
...mockContext,
input: { files: [{ from: '/core/../../../index.js', to: 'index.js' }] },
}),
).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should throw is trying to override by mistake', async () => {
const destFile = 'unit-test-c.js';
const filePath = resolvePath(workspacePath, destFile);
const beforeContent = fs.readFileSync(filePath, 'utf-8');
await expect(
action.handler({
...mockContext,
input: {
files: [
{
from: 'unit-test-a.js',
to: 'unit-test-c.js',
},
],
},
}),
).rejects.toThrow(/dest already exists/);
const afterContent = fs.readFileSync(filePath, 'utf-8');
expect(beforeContent).toEqual(afterContent);
});
it('should call fs.move with the correct values', async () => {
mockInputFiles.forEach(file => {
const filePath = resolvePath(workspacePath, file.from);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(true);
});
await action.handler(mockContext);
mockInputFiles.forEach(file => {
const filePath = resolvePath(workspacePath, file.from);
const fileExists = fs.existsSync(filePath);
expect(fileExists).toBe(false);
});
});
it('should override when requested', async () => {
const sourceFile = 'unit-test-a.js';
const destFile = 'unit-test-c.js';
const sourceFilePath = resolvePath(workspacePath, sourceFile);
const destFilePath = resolvePath(workspacePath, destFile);
const sourceBeforeContent = fs.readFileSync(sourceFilePath, 'utf-8');
const destBeforeContent = fs.readFileSync(destFilePath, 'utf-8');
expect(sourceBeforeContent).not.toEqual(destBeforeContent);
await action.handler({
...mockContext,
input: {
files: [
{
from: sourceFile,
to: destFile,
overwrite: true,
},
],
},
});
const destAfterContent = fs.readFileSync(destFilePath, 'utf-8');
expect(sourceBeforeContent).toEqual(destAfterContent);
});
});
@@ -0,0 +1,98 @@
/*
* Copyright 2021 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 { createTemplateAction } from '../../createTemplateAction';
import { resolveSafeChildPath } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import { JsonObject } from '@backstage/config';
import fs from 'fs-extra';
interface FilesToRename extends JsonObject {
from: string;
to: string;
}
export const createFilesystemRenameAction = () => {
return createTemplateAction<{ files: FilesToRename }>({
id: 'fs:rename',
description: 'Renames files and directories within the workspace',
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
description:
'A list of file and directory names that will be renamed',
type: 'array',
items: {
type: 'object',
required: ['from', 'to'],
properties: {
from: {
type: 'string',
title: 'The source location of the file to be renamed',
},
to: {
type: 'string',
title: 'The destination of the new file',
},
overwrite: {
type: 'boolean',
title:
'Overwrite existing file or directory, default is false',
},
},
},
},
},
},
},
async handler(ctx) {
if (!Array.isArray(ctx.input?.files)) {
throw new InputError('files must be an Array');
}
for (const file of ctx.input.files) {
if (!file.from || !file.to) {
throw new InputError('each file must have a from and to property');
}
const sourceFilepath = resolveSafeChildPath(
ctx.workspacePath,
file.from,
);
const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to);
try {
await fs.move(sourceFilepath, destFilepath, {
overwrite: file.overwrite ?? false,
});
ctx.logger.info(
`File ${sourceFilepath} renamed to ${destFilepath} successfully`,
);
} catch (err) {
ctx.logger.error(
`Failed to rename file ${sourceFilepath} to ${destFilepath}:`,
err,
);
throw err;
}
}
},
});
};
@@ -18,4 +18,5 @@ export * from './catalog';
export { createBuiltinActions } from './createBuiltinActions';
export * from './debug';
export * from './fetch';
export * from './filesystem';
export * from './publish';
@@ -162,6 +162,29 @@ describe('publish:azure', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call initRepoAndPush with the correct default branch', async () => {
mockGitClient.createRepository.mockImplementation(() => ({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
}));
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
});
@@ -30,6 +30,7 @@ export function createPublishAzureAction(options: {
return createTemplateAction<{
repoUrl: string;
description?: string;
defaultBranch?: string;
sourcePath?: string;
}>({
id: 'publish:azure',
@@ -48,6 +49,11 @@ export function createPublishAzureAction(options: {
title: 'Repository Description',
type: 'string',
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
@@ -70,9 +76,9 @@ export function createPublishAzureAction(options: {
},
},
async handler(ctx) {
const { owner, repo, host, organization } = parseRepoUrl(
ctx.input.repoUrl,
);
const { repoUrl, defaultBranch = 'master' } = ctx.input;
const { owner, repo, host, organization } = parseRepoUrl(repoUrl);
if (!organization) {
throw new InputError(
@@ -120,6 +126,7 @@ export function createPublishAzureAction(options: {
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
defaultBranch,
auth: {
username: 'notempty',
password: integrationConfig.config.token,
@@ -286,6 +286,49 @@ describe('publish:bitbucket', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
defaultBranch: 'master',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call initAndPush with the correct default branch', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/cloneurl',
},
],
},
}),
),
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
defaultBranch: 'main',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
});
@@ -190,6 +190,7 @@ export function createPublishBitbucketAction(options: {
return createTemplateAction<{
repoUrl: string;
description: string;
defaultBranch?: string;
repoVisibility: 'private' | 'public';
sourcePath?: string;
enableLFS: boolean;
@@ -215,6 +216,11 @@ export function createPublishBitbucketAction(options: {
type: 'string',
enum: ['private', 'public'],
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
@@ -245,6 +251,7 @@ export function createPublishBitbucketAction(options: {
const {
repoUrl,
description,
defaultBranch = 'master',
repoVisibility = 'private',
enableLFS = false,
} = ctx.input;
@@ -288,6 +295,7 @@ export function createPublishBitbucketAction(options: {
? integrationConfig.config.appPassword
: integrationConfig.config.token ?? '',
},
defaultBranch,
logger: ctx.logger,
});
@@ -175,6 +175,36 @@ describe('publish:github', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call initRepoAndPush with the correct defaultBranch main', async () => {
mockGithubClient.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'main',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
});
@@ -430,4 +460,34 @@ describe('publish:github', () => {
'https://github.com/html/url/blob/master',
);
});
it('should use main as default branch', async () => {
mockGithubClient.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/clone/url.git',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://github.com/html/url/blob/main',
);
});
});
@@ -45,6 +45,7 @@ export function createPublishGithubAction(options: {
repoUrl: string;
description?: string;
access?: string;
defaultBranch?: string;
sourcePath?: string;
repoVisibility: 'private' | 'internal' | 'public';
collaborators: Collaborator[];
@@ -77,6 +78,11 @@ export function createPublishGithubAction(options: {
type: 'string',
enum: ['private', 'public', 'internal'],
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
@@ -131,6 +137,7 @@ export function createPublishGithubAction(options: {
description,
access,
repoVisibility = 'private',
defaultBranch = 'master',
collaborators,
topics,
} = ctx.input;
@@ -239,11 +246,12 @@ export function createPublishGithubAction(options: {
}
const remoteUrl = newRepo.clone_url;
const repoContentsUrl = `${newRepo.html_url}/blob/master`;
const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`;
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
defaultBranch,
auth: {
username: 'x-access-token',
password: token,
@@ -257,10 +265,11 @@ export function createPublishGithubAction(options: {
client,
repoName: newRepo.name,
logger: ctx.logger,
defaultBranch,
});
} catch (e) {
throw new Error(
`Failed to add branch protection to '${newRepo.name}', ${e}`,
ctx.logger.warn(
`Skipping: default branch protection on '${newRepo.name}', ${e.message}`,
);
}
@@ -139,6 +139,30 @@ describe('publish:gitlab', () => {
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
defaultBranch: 'master',
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
});
});
it('should call initRepoAndPush with the correct default branch', async () => {
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
defaultBranch: 'main',
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
@@ -28,6 +28,7 @@ export function createPublishGitlabAction(options: {
return createTemplateAction<{
repoUrl: string;
defaultBranch?: string;
repoVisibility: 'private' | 'internal' | 'public';
sourcePath?: string;
}>({
@@ -48,6 +49,11 @@ export function createPublishGitlabAction(options: {
type: 'string',
enum: ['private', 'public', 'internal'],
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
@@ -70,7 +76,11 @@ export function createPublishGitlabAction(options: {
},
},
async handler(ctx) {
const { repoUrl, repoVisibility = 'private' } = ctx.input;
const {
repoUrl,
repoVisibility = 'private',
defaultBranch = 'master',
} = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
@@ -114,6 +124,7 @@ export function createPublishGitlabAction(options: {
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl: http_url_to_repo as string,
defaultBranch,
auth: {
username: 'oauth2',
password: integrationConfig.config.token,
@@ -24,11 +24,13 @@ export async function initRepoAndPush({
remoteUrl,
auth,
logger,
defaultBranch = 'master',
}: {
dir: string;
remoteUrl: string;
auth: { username: string; password: string };
logger: Logger;
defaultBranch?: string;
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
@@ -38,6 +40,7 @@ export async function initRepoAndPush({
await git.init({
dir,
defaultBranch,
});
const paths = await globby(['./**', './**/.*', '!.git'], {
@@ -74,6 +77,7 @@ type BranchProtectionOptions = {
owner: string;
repoName: string;
logger: Logger;
defaultBranch?: string;
};
export const enableBranchProtectionOnDefaultRepoBranch = async ({
@@ -81,6 +85,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({
client,
owner,
logger,
defaultBranch = 'master',
}: BranchProtectionOptions): Promise<void> => {
const tryOnce = async () => {
try {
@@ -97,7 +102,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({
},
owner,
repo: repoName,
branch: 'master',
branch: defaultBranch,
required_status_checks: { strict: true, contexts: [] },
restrictions: null,
enforce_admins: true,
+4 -1
View File
@@ -245,7 +245,10 @@ export class ScaffolderClient implements ScaffolderApi {
*/
async listActions(): Promise<ListActionsResponse> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const response = await fetch(`${baseUrl}/v2/actions`);
const token = await this.identityApi.getIdToken();
const response = await fetch(`${baseUrl}/v2/actions`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
throw ResponseError.fromResponse(response);
+7 -12
View File
@@ -16,24 +16,19 @@
import React, { useMemo } from 'react';
import { useObservable } from 'react-use';
import { makeStyles } from '@material-ui/core';
import PlayListAddIcon from '@material-ui/icons/PlaylistAdd';
import { ShortcutItem } from './ShortcutItem';
import { AddShortcut } from './AddShortcut';
import { shortcutsApiRef } from './api';
import { Progress, SidebarItem } from '@backstage/core-components';
import {
Progress,
SidebarItem,
SidebarScrollWrapper,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
const useStyles = makeStyles({
root: {
flex: '1 1 auto',
overflowY: 'scroll',
},
});
export const Shortcuts = () => {
const classes = useStyles();
const shortcutApi = useApi(shortcutsApiRef);
const shortcuts = useObservable(
useMemo(() => shortcutApi.shortcut$(), [shortcutApi]),
@@ -50,7 +45,7 @@ export const Shortcuts = () => {
};
return (
<div className={classes.root}>
<SidebarScrollWrapper>
<SidebarItem
icon={PlayListAddIcon}
text="Add Shortcuts"
@@ -72,6 +67,6 @@ export const Shortcuts = () => {
/>
))
)}
</div>
</SidebarScrollWrapper>
);
};
+1 -1
View File
@@ -40,7 +40,7 @@
"dockerode": "^3.2.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^9.0.1",
"fs-extra": "^10.0.0",
"knex": "^0.95.1",
"winston": "^3.2.1"
},