Merge branch 'master' into lintMod

This commit is contained in:
Fredrik Adelöw
2021-02-04 07:58:13 +01:00
50 changed files with 880 additions and 495 deletions
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiEntity,
Entity,
@@ -64,6 +65,10 @@ import {
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from '@backstage/plugin-pagerduty';
import {
isRollbarAvailable,
Router as RollbarRouter,
} from '@backstage/plugin-rollbar';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
@@ -153,6 +158,15 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
);
};
export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => {
switch (true) {
case isRollbarAvailable(entity):
return <RollbarRouter entity={entity} />;
default:
return <SentryRouter entity={entity} />;
}
};
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
@@ -212,9 +226,9 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
path="/errors/*"
title="Errors"
element={<ErrorsSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/api/*"
@@ -267,9 +281,9 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
element={<LighthouseRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
path="/errors/*"
title="Errors"
element={<ErrorsSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -76,6 +76,8 @@ function withRetries(count: number, fn: () => Promise<void>) {
}
describe('UrlReaders', () => {
jest.setTimeout(30_000);
it(
'should read data from azure',
withRetries(3, async () => {
@@ -27,6 +27,7 @@ export type {
} from './Entity';
export * from './policies';
export {
compareEntityToRef,
getEntityName,
parseEntityName,
parseEntityRef,
+322 -1
View File
@@ -16,7 +16,12 @@
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
import {
compareEntityToRef,
parseEntityName,
parseEntityRef,
serializeEntityRef,
} from './ref';
describe('ref', () => {
describe('parseEntityName', () => {
@@ -381,4 +386,320 @@ describe('ref', () => {
).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' });
});
});
describe('compareEntityToRef', () => {
const entityWithNamespace: Entity = {
apiVersion: 'a',
kind: 'K',
metadata: {
name: 'n',
namespace: 'ns',
},
};
const entityWithoutNamespace: Entity = {
apiVersion: 'a',
kind: 'K',
metadata: {
name: 'n',
},
};
it('handles matching string refs', () => {
expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true);
expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'K:n', {
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'K:n', {
defaultNamespace: 'Ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'N', {
defaultKind: 'k',
defaultNamespace: 'nS',
}),
).toBe(true);
expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
true,
);
expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe(
true,
);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'default',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'deFault',
}),
).toBe(true);
expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
true,
);
expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'default/n', {
defaultKind: 'K',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'default',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'n', {
defaultKind: 'K',
}),
).toBe(true);
});
it('handles mismatching string refs', () => {
expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'ns/n', {
defaultKind: 'X',
}),
).toBe(false);
expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'xx',
}),
).toBe(false);
expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'x', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(false);
});
it('handles matching compound refs', () => {
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'ns',
name: 'n',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'k',
namespace: 'Ns',
name: 'N',
}),
).toBe(true);
expect(
compareEntityToRef(
entityWithNamespace,
{ kind: 'K', name: 'n' },
{
defaultNamespace: 'ns',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithNamespace,
{ namespace: 'ns', name: 'n' },
{ defaultKind: 'K' },
),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'N', {
defaultKind: 'k',
defaultNamespace: 'nS',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'default',
name: 'n',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'k',
namespace: 'deFault',
name: 'N',
}),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ kind: 'K', name: 'n' },
{
defaultNamespace: 'default',
},
),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ namespace: 'default', name: 'n' },
{
defaultKind: 'K',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'n' },
{
defaultKind: 'K',
defaultNamespace: 'default',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'N' },
{
defaultKind: 'k',
defaultNamespace: 'defAult',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'n' },
{
defaultKind: 'K',
},
),
).toBe(true);
});
it('handles mismatching compound refs', () => {
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'X',
namespace: 'ns',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithNamespace,
{
namespace: 'ns',
name: 'n',
},
{ defaultKind: 'X' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'X',
namespace: 'default',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
namespace: 'default',
name: 'n',
},
{ defaultKind: 'X' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'xx',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithNamespace,
{
kind: 'K',
name: 'n',
},
{ defaultNamespace: 'xx' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'xx',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
kind: 'K',
name: 'n',
},
{ defaultNamespace: 'xx' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'ns',
name: 'x',
}),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'default',
name: 'x',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
kind: 'K',
name: 'x',
},
{ defaultNamespace: 'default' },
),
).toBe(false);
});
});
});
+75 -10
View File
@@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types';
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
function parseRefString(
ref: string,
): {
kind?: string;
namespace?: string;
name: string;
} {
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
if (!match) {
throw new TypeError(
`Entity reference "${ref}" was not on the form [<kind>:][<namespace>/]<name>`,
);
}
return {
kind: match[1]?.slice(0, -1),
namespace: match[2]?.slice(0, -1),
name: match[3],
};
}
/**
* Extracts the kind, namespace and name that form the name triplet of the
* given entity.
@@ -121,17 +142,11 @@ export function parseEntityRef(
}
if (typeof ref === 'string') {
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
if (!match) {
throw new Error(
`Entity reference "${ref}" was not on the form [<kind>:][<namespace>/]<name>`,
);
}
const parsed = parseRefString(ref);
return {
kind: match[1]?.slice(0, -1) ?? context.defaultKind,
namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace,
name: match[3],
kind: parsed.kind ?? context.defaultKind,
namespace: parsed.namespace ?? context.defaultNamespace,
name: parsed.name,
};
}
@@ -196,3 +211,53 @@ export function serializeEntityRef(
return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`;
}
/**
* Compares an entity to either a string reference or a compound reference.
*
* The comparison is case insensitive, and all of kind, namespace, and name
* must match (after applying the optional context to the ref).
*
* @param entity The entity to match
* @param ref A string or compound entity ref
* @param context An optional context of default kind and namespace, that apply
* to the ref if given
* @returns True if matching, false otherwise
*/
export function compareEntityToRef(
entity: Entity,
ref: EntityRef | EntityName,
context?: EntityRefContext,
): boolean {
const entityKind = entity.kind;
const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE;
const entityName = entity.metadata.name;
let refKind: string | undefined;
let refNamespace: string | undefined;
let refName: string;
if (typeof ref === 'string') {
const parsed = parseRefString(ref);
refKind = parsed.kind || context?.defaultKind;
refNamespace =
parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
refName = parsed.name;
} else {
refKind = ref.kind || context?.defaultKind;
refNamespace =
ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
refName = ref.name;
}
if (!refKind || !refNamespace) {
throw new Error(
`Entity reference or context did not contain kind and namespace`,
);
}
return (
entityKind.toLowerCase() === refKind.toLowerCase() &&
entityNamespace.toLowerCase() === refNamespace.toLowerCase() &&
entityName.toLowerCase() === refName.toLowerCase()
);
}
@@ -73,4 +73,19 @@ describe('ScmIntegrations', () => {
expect(i.byHost('github.local')).toBe(github);
expect(i.byHost('gitlab.local')).toBe(gitlab);
});
it('can resolveUrl using fallback', () => {
expect(
i.resolveUrl({
url: '../b.yaml',
base: 'https://no-matching-integration.com/x/a.yaml',
}),
).toBe('https://no-matching-integration.com/b.yaml');
expect(
i.resolveUrl({
url: 'https://absolute.com/path',
base: 'https://no-matching-integration.com/x/a.yaml',
}),
).toBe('https://absolute.com/path');
});
});
@@ -81,4 +81,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
.map(i => i.byHost(host))
.find(Boolean);
}
resolveUrl(options: { url: string; base: string }): string {
const resolve = this.byUrl(options.base)?.resolveUrl;
if (!resolve) {
return new URL(options.url, options.base).toString();
}
return resolve(options);
}
}
@@ -41,4 +41,52 @@ describe('AzureIntegration', () => {
expect(integration.type).toBe('azure');
expect(integration.title).toBe('h.com');
});
describe('resolveUrl', () => {
it('works for valid urls', () => {
const integration = new AzureIntegration({
host: 'dev.azure.com',
} as any);
expect(
integration.resolveUrl({
url: '../a.yaml',
base:
'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml',
}),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: './a.yaml',
base: 'https://dev.azure.com/organization/project/_git/repository',
}),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: 'https://absolute.com/path',
base:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
}),
).toBe('https://absolute.com/path');
});
it('falls back to regular URL resolution if not in a repo', () => {
const integration = new AzureIntegration({
host: 'dev.azure.com',
} as any);
expect(
integration.resolveUrl({
url: './test',
base: 'https://dev.azure.com/organization/project/_git',
}),
).toBe('https://dev.azure.com/organization/project/test');
});
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
@@ -42,4 +43,39 @@ export class AzureIntegration implements ScmIntegration {
get config(): AzureIntegrationConfig {
return this.integrationConfig;
}
/*
* Azure repo URLs on the form with a `path` query param are treated specially.
*
* Example base URL: https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml
*/
resolveUrl(options: { url: string; base: string }): string {
const { url, base } = options;
// If we can parse the url, it is absolute - then return it verbatim
try {
// eslint-disable-next-line no-new
new URL(url);
return url;
} catch {
// Ignore intentionally - looks like a relative path
}
const parsed = parseGitUrl(base);
const { organization, owner, name, filepath } = parsed;
// If not an actual file path within a repo, treat the URL as raw
if (!organization || !owner || !name) {
return new URL(url, base).toString();
}
const path = filepath?.replace(/^\//, '') || '';
const mockBaseUrl = new URL(`https://a.com/${path}`);
const updatedPath = new URL(url, mockBaseUrl).pathname;
const newUrl = new URL(base);
newUrl.searchParams.set('path', updatedPath);
return newUrl.toString();
}
}
@@ -125,6 +125,7 @@ describe('bitbucket core', () => {
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
@@ -250,5 +251,40 @@ describe('bitbucket core', () => {
);
expect(defaultBranch).toEqual('main');
});
it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(404),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const defaultBranch = await getBitbucketDefaultBranch(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md',
config,
);
expect(defaultBranch).toEqual('main');
});
});
});
+10 -2
View File
@@ -32,11 +32,19 @@ export async function getBitbucketDefaultBranch(
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184
const branchUrl = isHosted
let branchUrl = isHosted
? `${config.apiBaseUrl}/repositories/${project}/${repoName}`
: `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`;
const response = await fetch(branchUrl, getBitbucketRequestOptions(config));
let response = await fetch(branchUrl, getBitbucketRequestOptions(config));
if (response.status === 404 && !isHosted) {
// First try the new format, and then if it gets specifically a 404 it should try the old format
// (to support old Atlassian Bitbucket v5.11.1 format )
branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`;
response = await fetch(branchUrl, getBitbucketRequestOptions(config));
}
if (!response.ok) {
const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`;
throw new Error(message);
+6 -6
View File
@@ -49,23 +49,23 @@ describe('gitlab core', () => {
{
config: configWithNoToken,
url:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml',
result:
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
{
config: configWithToken,
url:
'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml',
result:
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
{
config: configWithNoToken,
url:
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', // Repo not in subgroup
result:
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
// Raw URLs
{
+1 -1
View File
@@ -109,7 +109,7 @@ export function buildProjectUrl(target: string, projectID: Number): URL {
'/api/v4/projects',
projectID,
'repository/files',
encodeURIComponent(filePath.join('/')),
encodeURIComponent(decodeURIComponent(filePath.join('/'))),
'raw',
].join('/');
url.search = `?ref=${branch}`;
+21
View File
@@ -34,6 +34,18 @@ export interface ScmIntegration {
* differentiate between different integrations.
*/
title: string;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
*
* If this method is not implemented, the URL constructor is used instead for
* URLs that match this integration.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl?(options: { url: string; base: string }): string;
}
/**
@@ -69,6 +81,15 @@ export interface ScmIntegrationRegistry
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
github: ScmIntegrationsGroup<GitHubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
}
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {