Merge branch 'master' of github.com:spotify/backstage into migrate-to-msw
* 'master' of github.com:spotify/backstage: Read git auth token from backend config (#2992) chore(catalog-model): add the petstore into the default entities chore(deps-dev): bump @storybook/addon-actions from 6.0.21 to 6.0.26 chore(deps): bump eslint-config-prettier from 6.10.0 to 6.14.0 Add API docs link to About card adds a sample organization set of users and groups (#2963) Make sure hasCostsWithinTimeframe is a boolean fix(catalog-backend): fix codeowners processor to handle users feat(catalog): add simple client side paging default to last 30 days Removed default-branch library since it's no longer used (#3019) useRealTimers in onCssReady test case move type dependecy remove unused import changeset simpler query params
This commit is contained in:
@@ -123,6 +123,10 @@ describe('CodeOwnersProcessor', () => {
|
||||
});
|
||||
|
||||
describe('normalizeCodeOwner', () => {
|
||||
it('should remove the @ symbol', () => {
|
||||
expect(normalizeCodeOwner('@yoda')).toBe('yoda');
|
||||
});
|
||||
|
||||
it('should remove org from org/team format', () => {
|
||||
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
|
||||
});
|
||||
|
||||
@@ -127,6 +127,8 @@ export function findPrimaryCodeOwner(
|
||||
export function normalizeCodeOwner(owner: string) {
|
||||
if (owner.match(/^@.*\/.*/)) {
|
||||
return owner.split('/')[1];
|
||||
} else if (owner.match(/^@.*/)) {
|
||||
return owner.substring(1);
|
||||
} else if (owner.match(/^.*@.*\..*$/)) {
|
||||
return owner.split('@')[0];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import BrightnessAutoIcon from '@material-ui/icons/BrightnessAuto';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
@@ -120,6 +121,14 @@ export function AboutCard({ entity, variant }: AboutCardProps) {
|
||||
entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind}/${entity.metadata.name}`}
|
||||
/>
|
||||
<IconLinkVertical
|
||||
disabled={!entity.spec?.implementsApis}
|
||||
label="View API"
|
||||
icon={<BrightnessAutoIcon />}
|
||||
href={`./${
|
||||
entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
|
||||
}:${entity.metadata.name}/api`}
|
||||
/>
|
||||
</nav>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -155,7 +155,8 @@ export const CatalogTable = ({
|
||||
isLoading={loading}
|
||||
columns={columns}
|
||||
options={{
|
||||
paging: false,
|
||||
paging: true,
|
||||
pageSize: 10,
|
||||
actionsColumnIndex: -1,
|
||||
loadingType: 'linear',
|
||||
showEmptyDataSourceMessage: !loading,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3",
|
||||
"recharts": "^1.8.5"
|
||||
"recharts": "^1.8.5",
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.25",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/yup": "^0.29.8",
|
||||
"@types/recharts": "^1.8.14",
|
||||
"msw": "^0.21.2",
|
||||
"cross-fetch": "^3.0.6",
|
||||
|
||||
@@ -51,7 +51,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
|
||||
const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]);
|
||||
|
||||
const amount = resource?.entities?.length || 0;
|
||||
const hasCostsWithinTimeframe = resource?.change && amount;
|
||||
const hasCostsWithinTimeframe = resource?.change && !!amount;
|
||||
|
||||
const subheader = amount
|
||||
? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost`
|
||||
|
||||
@@ -20,5 +20,4 @@ export * from './useFilters';
|
||||
export * from './useCurrency';
|
||||
export * from './useGroups';
|
||||
export * from './useLoading';
|
||||
export * from './useQueryParams';
|
||||
export * from './useScroll';
|
||||
|
||||
@@ -16,114 +16,98 @@
|
||||
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
PropsWithChildren,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
getDefaultPageFilters,
|
||||
PageFilters,
|
||||
ProductFilters,
|
||||
Duration,
|
||||
Group,
|
||||
} from '../types';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Maybe, PageFilters, ProductFilters } from '../types';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryParams } from './useQueryParams';
|
||||
import { stringify } from '../utils/history';
|
||||
import {
|
||||
stringify,
|
||||
validate,
|
||||
getInitialPageState,
|
||||
getInitialProductState,
|
||||
} from '../utils/history';
|
||||
import { useGroups } from './useGroups';
|
||||
import { useConfig } from './useConfig';
|
||||
|
||||
const getInitialPageState = (
|
||||
groups: Group[],
|
||||
queryParams?: Partial<PageFilters>,
|
||||
) => {
|
||||
// The group is written initially to queryParams as null, since user groups are asynchronously
|
||||
// loaded. We preserve nulls in queryParams for other parameters where null is meaningful; for
|
||||
// group, avoid overwriting the default with null after groups are loaded.
|
||||
const { group, ...otherParams } = queryParams || {};
|
||||
return {
|
||||
...getDefaultPageFilters(groups),
|
||||
...otherParams,
|
||||
...(group ? { group: group } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export type FilterContextProps = {
|
||||
pageFilters: PageFilters;
|
||||
productFilters: ProductFilters;
|
||||
setPageFilters: Dispatch<SetStateAction<PageFilters>>;
|
||||
setProductFilters: Dispatch<SetStateAction<ProductFilters>>;
|
||||
setPageFilters: Dispatch<SetStateAction<Maybe<PageFilters>>>;
|
||||
setProductFilters: Dispatch<SetStateAction<Maybe<ProductFilters>>>;
|
||||
};
|
||||
|
||||
export type MapFiltersToProps<T> = (props: FilterContextProps) => T;
|
||||
|
||||
export type FilterProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const FilterContext = React.createContext<
|
||||
FilterContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const FilterProvider = ({ children }: FilterProviderProps) => {
|
||||
export const FilterProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const config = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryParams = useQueryParams();
|
||||
const qsRef = useRef('');
|
||||
const groups = useGroups();
|
||||
|
||||
const defaultProductFilters = config.products.map(product => ({
|
||||
productType: product.kind,
|
||||
duration: Duration.P1M,
|
||||
}));
|
||||
|
||||
const getInitialProductState = (productFilters?: ProductFilters) => {
|
||||
if (!productFilters) return defaultProductFilters;
|
||||
return defaultProductFilters.map(product => {
|
||||
return (
|
||||
productFilters.find(
|
||||
param => param.productType === product.productType,
|
||||
) || product
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const [productFilters, setProductFilters] = useState(
|
||||
getInitialProductState(queryParams.productFilters),
|
||||
);
|
||||
const [pageFilters, setPageFilters] = useState(
|
||||
getInitialPageState(groups, queryParams.pageFilters),
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
const [pageFilters, setPageFilters] = useState<Maybe<PageFilters>>(null);
|
||||
const [productFilters, setProductFilters] = useState<Maybe<ProductFilters>>(
|
||||
null,
|
||||
);
|
||||
|
||||
// TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded.
|
||||
useEffect(() => {
|
||||
const initialState = getInitialPageState(groups, queryParams.pageFilters);
|
||||
const defaultMetric = config.metrics.find(m => m.default);
|
||||
setPageFilters({ ...initialState, metric: defaultMetric?.kind ?? null });
|
||||
async function setPageFiltersFromLocation() {
|
||||
try {
|
||||
// strip extraneous parameters, validate and transform
|
||||
const queryParams = await validate(location.search);
|
||||
const defaultMetric = config.metrics.find(m => m.default)?.kind ?? null;
|
||||
|
||||
// Group or project parameters should override defaults
|
||||
const initialPageState = getInitialPageState(groups, queryParams);
|
||||
const initialProductState = getInitialProductState(config);
|
||||
|
||||
setProductFilters(initialProductState);
|
||||
setPageFilters({ ...initialPageState, metric: defaultMetric });
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
}
|
||||
|
||||
setPageFiltersFromLocation();
|
||||
}, [groups]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const queryString = stringify({ ...pageFilters, products: productFilters });
|
||||
if (queryString === qsRef.current) return;
|
||||
qsRef.current = queryString;
|
||||
// TODO Remove workaround once issue is resolved in react-router
|
||||
// (https://github.com/ReactTraining/react-router/issues/7496)
|
||||
// navigate({ ...location, search: queryString });
|
||||
navigate({ ...location, search: `?${queryString}` });
|
||||
}, [pageFilters, productFilters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
function setLocationFromPageFilters(filters: PageFilters) {
|
||||
const queryString = stringify({
|
||||
group: filters.group,
|
||||
...(filters.project ? { project: filters.project } : {}),
|
||||
});
|
||||
// TODO Remove workaround once issue is resolved in react-router
|
||||
// (https://github.com/ReactTraining/react-router/issues/7496)
|
||||
// navigate({ ...location, search: queryString });
|
||||
navigate({ ...location, search: `?${queryString}` });
|
||||
}
|
||||
|
||||
if (pageFilters) {
|
||||
setLocationFromPageFilters(pageFilters);
|
||||
}
|
||||
}, [pageFilters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
// Wait for filters to load
|
||||
if (!pageFilters || !productFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{
|
||||
pageFilters: pageFilters,
|
||||
productFilters: productFilters,
|
||||
setPageFilters: setPageFilters,
|
||||
setProductFilters: setProductFilters,
|
||||
}}
|
||||
value={{ pageFilters, productFilters, setPageFilters, setProductFilters }}
|
||||
>
|
||||
{children}
|
||||
</FilterContext.Provider>
|
||||
@@ -132,17 +116,7 @@ export const FilterProvider = ({ children }: FilterProviderProps) => {
|
||||
|
||||
export function useFilters<T>(mapFiltersToProps: MapFiltersToProps<T>): T {
|
||||
const context = useContext(FilterContext);
|
||||
|
||||
if (!context) {
|
||||
assertNever();
|
||||
}
|
||||
|
||||
return mapFiltersToProps({
|
||||
pageFilters: context.pageFilters,
|
||||
productFilters: context.productFilters,
|
||||
setPageFilters: context.setPageFilters,
|
||||
setProductFilters: context.setProductFilters,
|
||||
});
|
||||
return context ? mapFiltersToProps(context) : assertNever();
|
||||
}
|
||||
|
||||
function assertNever(): never {
|
||||
|
||||
@@ -14,11 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useApi, identityApiRef } from '@backstage/core';
|
||||
import { costInsightsApiRef } from '../api';
|
||||
import { MapLoadingToProps, useLoading } from './useLoading';
|
||||
import { DefaultLoadingAction, Group } from '../types';
|
||||
import { DefaultLoadingAction, Group, Maybe } from '../types';
|
||||
|
||||
type GroupsProviderLoadingProps = {
|
||||
dispatchLoadingGroups: (isLoading: boolean) => void;
|
||||
@@ -35,33 +41,41 @@ type GroupsContextProps = {
|
||||
groups: Group[];
|
||||
};
|
||||
|
||||
export type GroupsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export const GroupsContext = React.createContext<
|
||||
GroupsContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const GroupsContext = React.createContext<GroupsContextProps>({
|
||||
groups: [],
|
||||
});
|
||||
|
||||
export const GroupsProvider = ({ children }: GroupsProviderProps) => {
|
||||
export const GroupsProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const [error, setError] = useState<Maybe<Error>>(null);
|
||||
const { dispatchLoadingGroups } = useLoading(mapLoadingToProps);
|
||||
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [groups, setGroups] = useState<Maybe<Group[]>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchLoadingGroups(true);
|
||||
|
||||
async function getUserGroups() {
|
||||
const g = await client.getUserGroups(userId);
|
||||
setGroups(g);
|
||||
dispatchLoadingGroups(false);
|
||||
try {
|
||||
const g = await client.getUserGroups(userId);
|
||||
setGroups(g);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
dispatchLoadingGroups(false);
|
||||
}
|
||||
}
|
||||
|
||||
getUserGroups();
|
||||
}, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
if (!groups) return null;
|
||||
|
||||
return (
|
||||
<GroupsContext.Provider value={{ groups: groups }}>
|
||||
{children}
|
||||
@@ -70,13 +84,8 @@ export const GroupsProvider = ({ children }: GroupsProviderProps) => {
|
||||
};
|
||||
|
||||
export function useGroups(): Group[] {
|
||||
const { groups } = useContext(GroupsContext);
|
||||
|
||||
if (!groups) {
|
||||
assertNever();
|
||||
}
|
||||
|
||||
return groups;
|
||||
const context = useContext(GroupsContext);
|
||||
return context ? context.groups : assertNever();
|
||||
}
|
||||
|
||||
function assertNever(): never {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Location } from 'history';
|
||||
import { PageFilters, ProductFilters } from '../types';
|
||||
import { parse } from '../utils/history';
|
||||
|
||||
export type FilterParams = {
|
||||
pageFilters?: Partial<PageFilters>;
|
||||
productFilters?: ProductFilters;
|
||||
};
|
||||
|
||||
export function useQueryParams(): FilterParams {
|
||||
const location: Location = useLocation();
|
||||
const { products: productFilters, ...pageFilters } = parse(location.search);
|
||||
|
||||
return {
|
||||
productFilters: productFilters,
|
||||
pageFilters: pageFilters,
|
||||
};
|
||||
}
|
||||
@@ -27,8 +27,6 @@ export interface PageFilters {
|
||||
|
||||
export type ProductFilters = Array<ProductPeriod>;
|
||||
|
||||
export type QueryParams = PageFilters & { products: ProductFilters };
|
||||
|
||||
export interface ProductPeriod {
|
||||
duration: Duration;
|
||||
productType: string;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { validate, getInitialPageState } from './history';
|
||||
|
||||
describe('getInitialPageState', () => {
|
||||
describe('groups', () => {
|
||||
it('should set defaults if params or group is not provided', () => {
|
||||
const initialState = getInitialPageState([]);
|
||||
expect(initialState.group).toBe(null);
|
||||
});
|
||||
|
||||
it('should set defaults if a group is fetched but no group is present on query params', () => {
|
||||
const initialState = getInitialPageState([{ id: 'group' }]);
|
||||
expect(initialState.group).toMatch(/group/);
|
||||
});
|
||||
|
||||
it('group param should always override fetched group', () => {
|
||||
const initialState = getInitialPageState(
|
||||
[{ id: 'group' }, { id: 'second-group' }],
|
||||
{ group: 'other-group' },
|
||||
);
|
||||
expect(initialState.group).toMatch(/other-group/);
|
||||
});
|
||||
|
||||
it('first group should be set as default group if user belongs to multiple groups', () => {
|
||||
const initialState = getInitialPageState([
|
||||
{ id: 'group' },
|
||||
{ id: 'other-group' },
|
||||
]);
|
||||
expect(initialState.group).toMatch(/group/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projects', () => {
|
||||
it("should set defaults if project param doesn't exist", () => {
|
||||
const initialState = getInitialPageState([], {});
|
||||
expect(initialState.project).toBeNull();
|
||||
});
|
||||
|
||||
it('should override defaults if project param exists', () => {
|
||||
const initialState = getInitialPageState([], { project: 'some-project' });
|
||||
expect(initialState.project).toMatch(/some-project/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
params | expected
|
||||
${''} | ${{}}
|
||||
${'?foo=bar'} | ${{}}
|
||||
${'?project'} | ${{ project: null }}
|
||||
${'?group=some-group'} | ${{ group: 'some-group' }}
|
||||
${'?group=some-group&project'} | ${{ group: 'some-group', project: null }}
|
||||
${'?group=some-group&project=some-project'} | ${{ group: 'some-group', project: 'some-project' }}
|
||||
${'?group=some-group&project=some-project&foo=bar'} | ${{ group: 'some-group', project: 'some-project' }}
|
||||
`('validate', ({ params, expected }) => {
|
||||
it(`should validate ${params}`, async () => {
|
||||
const pageFilters = await validate(params);
|
||||
expect(pageFilters).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it("should throw an error if param values don't match schema", async () => {
|
||||
await expect(validate('?group')).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,44 @@
|
||||
*/
|
||||
|
||||
import qs from 'qs';
|
||||
import { QueryParams } from '../types';
|
||||
import * as yup from 'yup';
|
||||
import { Group, PageFilters, getDefaultPageFilters, Duration } from '../types';
|
||||
import { ConfigContextProps } from '../hooks/useConfig';
|
||||
|
||||
export const stringify = (queryParams: Partial<QueryParams>) =>
|
||||
const schema = yup
|
||||
.object()
|
||||
.shape({
|
||||
group: yup.string(),
|
||||
project: yup.string().nullable(),
|
||||
})
|
||||
.required();
|
||||
|
||||
export const stringify = (queryParams: Partial<PageFilters>) =>
|
||||
qs.stringify(queryParams, { strictNullHandling: true });
|
||||
export const parse = (queryString: string): Partial<QueryParams> =>
|
||||
|
||||
export const parse = (queryString: string): Partial<PageFilters> =>
|
||||
qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true });
|
||||
|
||||
export const validate = (queryString: string): Promise<PageFilters> => {
|
||||
return schema.validate(parse(queryString), {
|
||||
stripUnknown: true,
|
||||
strict: true,
|
||||
}) as Promise<PageFilters>;
|
||||
};
|
||||
|
||||
export const getInitialPageState = (
|
||||
groups: Group[],
|
||||
queryParams: Partial<PageFilters> = {},
|
||||
) => {
|
||||
return {
|
||||
...getDefaultPageFilters(groups),
|
||||
...(queryParams.project ? { project: queryParams.project } : {}),
|
||||
...(queryParams.group ? { group: queryParams.group } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getInitialProductState = (config: ConfigContextProps) =>
|
||||
config.products.map(product => ({
|
||||
productType: product.kind,
|
||||
duration: Duration.P30D,
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import {
|
||||
getDefaultPageFilters,
|
||||
Maybe,
|
||||
PageFilters,
|
||||
ProductFilters,
|
||||
Group,
|
||||
@@ -29,8 +30,8 @@ import { MockProductFilters } from './mockData';
|
||||
export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }];
|
||||
|
||||
type MockFilterProviderProps = {
|
||||
setPageFilters: Dispatch<SetStateAction<PageFilters>>;
|
||||
setProductFilters: Dispatch<SetStateAction<ProductFilters>>;
|
||||
setPageFilters: Dispatch<SetStateAction<Maybe<PageFilters>>>;
|
||||
setProductFilters: Dispatch<SetStateAction<Maybe<ProductFilters>>>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"@types/dockerode": "^2.5.34",
|
||||
"@types/express": "^4.17.6",
|
||||
"command-exists-promise": "^2.0.2",
|
||||
"default-branch": "^1.0.8",
|
||||
"dockerode": "^3.2.1",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
|
||||
@@ -17,6 +17,13 @@ import fetch from 'cross-fetch';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Config } from '@backstage/config';
|
||||
import { getRootLogger, loadBackendConfig } from '@backstage/backend-common';
|
||||
import {
|
||||
getAzureHostToken,
|
||||
getGitHost,
|
||||
getGithubHostToken,
|
||||
getGitlabHostToken,
|
||||
getGitRepoType,
|
||||
} from './git-auth';
|
||||
|
||||
interface IGitlabBranch {
|
||||
name: string;
|
||||
@@ -73,15 +80,12 @@ function getAzureApiUrl(url: string): URL {
|
||||
);
|
||||
}
|
||||
|
||||
function getGithubRequestOptions(config: Config): RequestInit {
|
||||
function getGithubRequestOptions(config: Config, host: string): RequestInit {
|
||||
const headers: HeadersInit = {
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
};
|
||||
|
||||
const token =
|
||||
config.getOptionalString('catalog.processors.github.privateToken') ??
|
||||
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
|
||||
process.env.GITHUB_TOKEN;
|
||||
const token = getGithubHostToken(config, host);
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
@@ -92,16 +96,12 @@ function getGithubRequestOptions(config: Config): RequestInit {
|
||||
};
|
||||
}
|
||||
|
||||
function getGitlabRequestOptions(config: Config): RequestInit {
|
||||
function getGitlabRequestOptions(config: Config, host: string): RequestInit {
|
||||
const headers: HeadersInit = {
|
||||
'PRIVATE-TOKEN': '',
|
||||
};
|
||||
|
||||
const token =
|
||||
config.getOptionalString('catalog.processors.gitlab.privateToken') ??
|
||||
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
|
||||
process.env.GITLAB_TOKEN;
|
||||
|
||||
const token = getGitlabHostToken(config, host);
|
||||
if (token) {
|
||||
headers['PRIVATE-TOKEN'] = token;
|
||||
}
|
||||
@@ -111,12 +111,10 @@ function getGitlabRequestOptions(config: Config): RequestInit {
|
||||
};
|
||||
}
|
||||
|
||||
function getAzureRequestOptions(config: Config): RequestInit {
|
||||
function getAzureRequestOptions(config: Config, host: string): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
const token =
|
||||
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
|
||||
process.env.AZURE_TOKEN;
|
||||
const token = getAzureHostToken(config, host);
|
||||
|
||||
if (token !== '') {
|
||||
headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString(
|
||||
@@ -136,7 +134,8 @@ async function getGithubDefaultBranch(
|
||||
config: Config,
|
||||
): Promise<string> {
|
||||
const path = getGithubApiUrl(config, repositoryUrl).toString();
|
||||
const options = getGithubRequestOptions(config);
|
||||
const host = getGitHost(repositoryUrl);
|
||||
const options = getGithubRequestOptions(config, host);
|
||||
|
||||
try {
|
||||
const raw = await fetch(path, options);
|
||||
@@ -165,7 +164,8 @@ async function getGitlabDefaultBranch(
|
||||
): Promise<string> {
|
||||
const path = getGitlabApiUrl(repositoryUrl).toString();
|
||||
|
||||
const options = getGitlabRequestOptions(config);
|
||||
const gitlabHost = getGitHost(repositoryUrl);
|
||||
const options = getGitlabRequestOptions(config, gitlabHost);
|
||||
|
||||
try {
|
||||
const raw = await fetch(path, options);
|
||||
@@ -196,8 +196,8 @@ async function getAzureDefaultBranch(
|
||||
config: Config,
|
||||
): Promise<string> {
|
||||
const path = getAzureApiUrl(repositoryUrl).toString();
|
||||
|
||||
const options = getAzureRequestOptions(config);
|
||||
const host = getGitHost(repositoryUrl);
|
||||
const options = getAzureRequestOptions(config, host);
|
||||
|
||||
try {
|
||||
const urlResponse = await fetch(path, options);
|
||||
@@ -232,14 +232,7 @@ export const getDefaultBranch = async (
|
||||
): Promise<string> => {
|
||||
// TODO(Rugvip): Config should not be loaded here, pass it in instead
|
||||
const config = await loadBackendConfig({ logger: getRootLogger() });
|
||||
const typeMapping = [
|
||||
{ url: /github*/g, type: 'github' },
|
||||
{ url: /gitlab*/g, type: 'gitlab' },
|
||||
{ url: /azure*/g, type: 'azure/api' },
|
||||
];
|
||||
|
||||
const type = typeMapping.filter(item => item.url.test(repositoryUrl))[0]
|
||||
?.type;
|
||||
const type = getGitRepoType(repositoryUrl);
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Config } from '@backstage/config';
|
||||
import { getRootLogger, loadBackendConfig } from '@backstage/backend-common';
|
||||
|
||||
export function getGitHost(url: string): string {
|
||||
const { resource } = parseGitUrl(url);
|
||||
return resource;
|
||||
}
|
||||
|
||||
export function getGitRepoType(url: string): string {
|
||||
const typeMapping = [
|
||||
{ url: /github*/g, type: 'github' },
|
||||
{ url: /gitlab*/g, type: 'gitlab' },
|
||||
{ url: /azure*/g, type: 'azure/api' },
|
||||
];
|
||||
|
||||
const type = typeMapping.filter(item => item.url.test(url))[0]?.type;
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export function getGithubHostToken(
|
||||
config: Config,
|
||||
host: string,
|
||||
): string | undefined {
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.github') ?? [];
|
||||
|
||||
const hostConfig = providerConfigs.filter(
|
||||
providerConfig => providerConfig.getOptionalString('host') === host,
|
||||
);
|
||||
const token =
|
||||
hostConfig[0]?.getOptionalString('token') ??
|
||||
config.getOptionalString('catalog.processors.github.privateToken') ??
|
||||
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
|
||||
process.env.GITHUB_TOKEN;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getGitlabHostToken(
|
||||
config: Config,
|
||||
host: string,
|
||||
): string | undefined {
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.gitlab') ?? [];
|
||||
|
||||
const hostConfig = providerConfigs.filter(
|
||||
providerConfig => providerConfig.getOptionalString('host') === host,
|
||||
);
|
||||
const token =
|
||||
hostConfig[0]?.getOptionalString('token') ??
|
||||
config.getOptionalString('catalog.processors.gitlab.privateToken') ??
|
||||
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
|
||||
process.env.GITLAB_TOKEN;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getAzureHostToken(
|
||||
config: Config,
|
||||
host: string,
|
||||
): string | undefined {
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [];
|
||||
|
||||
const hostConfig = providerConfigs.filter(
|
||||
providerConfig => providerConfig.getOptionalString('host') === host,
|
||||
);
|
||||
const token =
|
||||
hostConfig[0]?.getOptionalString('token') ??
|
||||
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
|
||||
process.env.AZURE_TOKEN;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export const getTokenForGitRepo = async (
|
||||
repositoryUrl: string,
|
||||
): Promise<string | undefined> => {
|
||||
const config = await loadBackendConfig({ logger: getRootLogger() });
|
||||
|
||||
const host = getGitHost(repositoryUrl);
|
||||
const type = getGitRepoType(repositoryUrl);
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'github':
|
||||
return getGithubHostToken(config, host);
|
||||
case 'gitlab':
|
||||
return getGitlabHostToken(config, host);
|
||||
case 'azure/api':
|
||||
return getAzureHostToken(config, host);
|
||||
|
||||
default:
|
||||
throw new Error('Failed to get repository type');
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import parseGitUrl from 'git-url-parse';
|
||||
import NodeGit, { Clone, Repository } from 'nodegit';
|
||||
import fs from 'fs-extra';
|
||||
import { getDefaultBranch } from './default-branch';
|
||||
import { getTokenForGitRepo } from './git-auth';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { RemoteProtocol } from './techdocs/stages/prepare/types';
|
||||
@@ -127,11 +128,8 @@ export const checkoutGitRepository = async (
|
||||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
|
||||
process.env.AZURE_PRIVATE_TOKEN_USER ||
|
||||
'';
|
||||
const token =
|
||||
process.env.GITHUB_TOKEN ||
|
||||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
|
||||
process.env.AZURE_TOKEN ||
|
||||
'';
|
||||
|
||||
const token = await getTokenForGitRepo(repoUrl);
|
||||
|
||||
if (fs.existsSync(repositoryTmpPath)) {
|
||||
try {
|
||||
@@ -153,7 +151,7 @@ export const checkoutGitRepository = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (user && token) {
|
||||
if (token) {
|
||||
parsedGitLocation.token = `${user}:${token}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,20 @@ import { onCssReady } from '../transformers';
|
||||
const docStorageUrl: string =
|
||||
'https://techdocs-mock-sites.storage.googleapis.com';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const fixture = `
|
||||
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
|
||||
<link rel="stylesheet" href="http://example.com/test.css" />
|
||||
`;
|
||||
|
||||
describe('onCssReady', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockStylesheetEventListener(100);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user