Merge pull request #5228 from anderoo/anderoo/github-deployments-ghe

support GitHub enterprise and hosted GitHub on GithubDeployments plugin
This commit is contained in:
Ben Lambert
2021-05-12 11:34:17 +02:00
committed by GitHub
8 changed files with 309 additions and 50 deletions
+16
View File
@@ -52,3 +52,19 @@ spec:
owner: CNCF
lifecycle: experimental
```
### Self-hosted / Enterprise GitHub
The plugin will try to use `backstage.io/source-location` or `backstage.io/managed-by-location`
annotations to figure out the location of the source code.
1. Add the `host` and `apiBaseUrl` to your `app-config.yaml`
```yaml
# app-config.yaml
integrations:
github:
- host: 'your-github-host.com'
apiBaseUrl: 'https://api.your-github-host.com'
```
+3
View File
@@ -22,6 +22,9 @@
"dependencies": {
"@backstage/catalog-model": "^0.7.6",
"@backstage/core": "^0.7.8",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.1",
"@backstage/integration-react": "^0.1.1",
"@backstage/plugin-catalog-react": "^0.1.3",
"@backstage/theme": "^0.2.7",
"@material-ui/core": "^4.11.0",
+48 -11
View File
@@ -13,9 +13,42 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseLocationReference } from '@backstage/catalog-model';
import { createApiRef, OAuthApi } from '@backstage/core';
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { graphql } from '@octokit/graphql';
const getBaseUrl = (
scmIntegrationsApi: ScmIntegrationRegistry,
host: string | undefined,
): string => {
if (host === undefined) {
return 'https://api.github.com';
}
const location = parseLocationReference(host);
if (location.type !== 'github') {
return 'https://api.github.com';
}
const config = scmIntegrationsApi.github.byHost(location.target);
if (!config) {
throw new InputError(
`No matching GitHub integration configuration for host ${host}, please check your integrations config`,
);
}
if (!config.config.apiBaseUrl) {
throw new InputError(
`No apiBaseUrl available for host ${host}, please check your integrations config`,
);
}
return config?.config.apiBaseUrl;
};
export type GithubDeployment = {
environment: string;
state: string;
@@ -30,12 +63,15 @@ export type GithubDeployment = {
payload: string;
};
type QueryParams = {
host: string | undefined;
owner: string;
repo: string;
last: number;
};
export interface GithubDeploymentsApi {
listDeployments(options: {
owner: string;
repo: string;
last: number;
}): Promise<GithubDeployment[]>;
listDeployments(params: QueryParams): Promise<GithubDeployment[]>;
}
export const githubDeploymentsApiRef = createApiRef<GithubDeploymentsApi>({
@@ -45,6 +81,7 @@ export const githubDeploymentsApiRef = createApiRef<GithubDeploymentsApi>({
export type Options = {
githubAuthApi: OAuthApi;
scmIntegrationsApi: ScmIntegrationRegistry;
};
const deploymentsQuery = `
@@ -79,19 +116,19 @@ export type QueryResponse = {
export class GithubDeploymentsApiClient implements GithubDeploymentsApi {
private readonly githubAuthApi: OAuthApi;
private readonly scmIntegrationsApi: ScmIntegrationRegistry;
constructor(options: Options) {
this.githubAuthApi = options.githubAuthApi;
this.scmIntegrationsApi = options.scmIntegrationsApi;
}
async listDeployments(options: {
owner: string;
repo: string;
last: number;
}): Promise<GithubDeployment[]> {
async listDeployments(params: QueryParams): Promise<GithubDeployment[]> {
const baseUrl = getBaseUrl(this.scmIntegrationsApi, params.host);
const token = await this.githubAuthApi.getAccessToken(['repo']);
const graphQLWithAuth = graphql.defaults({
baseUrl,
headers: {
authorization: `token ${token}`,
},
@@ -99,7 +136,7 @@ export class GithubDeploymentsApiClient implements GithubDeploymentsApi {
const response: QueryResponse = await graphQLWithAuth(
deploymentsQuery,
options,
params,
);
return response.repository?.deployments?.nodes?.reverse() || [];
}
@@ -43,18 +43,48 @@ import {
import { setupServer } from 'msw/node';
import { graphql } from 'msw';
import { ScmIntegrations } from '@backstage/integration';
import { Entity } from '@backstage/catalog-model';
import { GithubDeploymentsTable } from './GithubDeploymentsTable';
import { Box } from '@material-ui/core';
let entity: { entity: Entity };
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => {
return entityStub;
return entity;
},
}));
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
const configApi: ConfigApi = new ConfigReader({});
const configApi: ConfigApi = new ConfigReader({
integrations: {
github: [
{
host: 'my-github-1.com',
apiBaseUrl: 'https://api.my-github-1.com',
},
{
host: 'my-github-2.com',
apiBaseUrl: 'https://api.my-github-2.com',
},
{
host: 'my-github-3.com',
},
],
},
});
const GRAPHQL_GITHUB_API = graphql.link('https://api.github.com/graphql');
const GRAPHQL_CUSTOM_API_1 = graphql.link(
'https://api.my-github-1.com/graphql',
);
const GRAPHQL_CUSTOM_API_2 = graphql.link(
'https://api.my-github-2.com/graphql',
);
const scmIntegrationsApi = ScmIntegrations.fromConfig(configApi);
const githubAuthApi: OAuthApi = {
getAccessToken: async _ => 'access_token',
};
@@ -62,14 +92,42 @@ const githubAuthApi: OAuthApi = {
const apis = ApiRegistry.from([
[configApiRef, configApi],
[errorApiRef, errorApiMock],
[githubDeploymentsApiRef, new GithubDeploymentsApiClient({ githubAuthApi })],
[
githubDeploymentsApiRef,
new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }),
],
]);
const assertFetchedData = async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(await rendered.findByText('GitHub Deployments')).toBeInTheDocument();
expect(await rendered.findByText('active')).toBeInTheDocument();
expect(await rendered.findByText('prd')).toBeInTheDocument();
expect(await rendered.findByText('12345')).toHaveAttribute(
'href',
'https://exampleapi.com/123456789',
);
expect(await rendered.findByText('pending')).toBeInTheDocument();
expect(await rendered.findByText('lab')).toBeInTheDocument();
expect(await rendered.findByText('54321')).toHaveAttribute(
'href',
'https://exampleapi.com/543212345',
);
};
describe('github-deployments', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
worker.resetHandlers();
jest.resetAllMocks();
});
@@ -80,40 +138,27 @@ describe('github-deployments', () => {
});
describe('GithubDeploymentsCard', () => {
it('should display fetched data', async () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
};
});
it('displays fetched data using default github api', async () => {
worker.use(
graphql.query('deployments', (_, res, ctx) =>
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(
await rendered.findByText('GitHub Deployments'),
).toBeInTheDocument();
expect(await rendered.findByText('active')).toBeInTheDocument();
expect(await rendered.findByText('prd')).toBeInTheDocument();
expect(await rendered.findByText('12345')).toHaveAttribute(
'href',
'https://exampleapi.com/123456789',
);
expect(await rendered.findByText('pending')).toBeInTheDocument();
expect(await rendered.findByText('lab')).toBeInTheDocument();
expect(await rendered.findByText('54321')).toHaveAttribute(
'href',
'https://exampleapi.com/543212345',
);
await assertFetchedData();
expect.assertions(7);
});
it('should display empty state when no data', async () => {
worker.use(
graphql.query('deployments', (_, res, ctx) =>
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(noDataResponseStub)),
),
);
@@ -132,9 +177,9 @@ describe('github-deployments', () => {
).toBeInTheDocument();
});
it('should shows new data on reload', async () => {
it('should show new data on reload', async () => {
worker.use(
graphql.query('deployments', (_, res, ctx) =>
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
@@ -152,7 +197,7 @@ describe('github-deployments', () => {
worker.resetHandlers();
worker.use(
graphql.query('deployments', (_, res, ctx) =>
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(refreshedResponseStub)),
),
);
@@ -199,5 +244,144 @@ describe('github-deployments', () => {
expect(await rendered.findByText('moon')).toBeInTheDocument();
expect(await rendered.findByText('sun')).toBeInTheDocument();
});
describe('entity with source location', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/source-location': 'github:my-github-1.com',
};
});
it('should fetch data using custom api', async () => {
worker.use(
GRAPHQL_CUSTOM_API_1.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
await assertFetchedData();
expect.assertions(7);
});
});
describe('entity with managed by location', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/managed-by-location': 'github:my-github-2.com',
};
});
it('should fetch data using custom api', async () => {
worker.use(
GRAPHQL_CUSTOM_API_2.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
await assertFetchedData();
expect.assertions(7);
});
});
describe('entity with location without baseApiURL', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/managed-by-location': 'github:my-github-3.com',
};
});
it('shows no apiBaseUrl error', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(
await rendered.findByText(
'Warning: No apiBaseUrl available for host github:my-github-3.com, please check your integrations config',
),
).toBeInTheDocument();
});
});
describe('entity with location without matching host', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/managed-by-location':
'github:my-github-unknown.com/org/repo',
};
});
it('shows no matching host error', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(
await rendered.findByText(
'Warning: No matching GitHub integration configuration for host github:my-github-unknown.com/org/repo, please check your integrations config',
),
).toBeInTheDocument();
});
});
describe('entity with url location type', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/source-location':
'url:my-favourite-url-location/org/repo',
'backstage.io/managed-by-location':
'url:my-favourite-url-location/org/repo',
};
});
it('displays fetched data using default github api', async () => {
worker.use(
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
await assertFetchedData();
expect.assertions(7);
});
});
describe('entity with other location types', () => {
beforeEach(() => {
entity = entityStub;
entity.entity.metadata.annotations = {
'github.com/project-slug': 'org/repo',
'backstage.io/source-location':
'some-other-managed-location:my-favourite-location/org/repo',
'backstage.io/managed-by-location':
'file:my-favourite-file/org/repo.yaml',
};
});
it('displays fetched data using default github api', async () => {
worker.use(
GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
await assertFetchedData();
expect.assertions(7);
});
});
});
});
@@ -28,21 +28,27 @@ import {
isGithubDeploymentsAvailable,
} from '../Router';
import { GithubDeploymentsTable } from './GithubDeploymentsTable/GithubDeploymentsTable';
import {
LOCATION_ANNOTATION,
SOURCE_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
const GithubDeploymentsComponent = ({
projectSlug,
last,
columns,
host,
}: {
projectSlug: string;
last: number;
columns: TableColumn<GithubDeployment>[];
host: string | undefined;
}) => {
const api = useApi(githubDeploymentsApiRef);
const [owner, repo] = projectSlug.split('/');
const { loading, value, error, retry: reload } = useAsyncRetry(
async () => await api.listDeployments({ owner, repo, last }),
async () => await api.listDeployments({ host, owner, repo, last }),
);
if (error) {
@@ -67,6 +73,10 @@ export const GithubDeploymentsCard = ({
columns?: TableColumn<GithubDeployment>[];
}) => {
const { entity } = useEntity();
const [host] = [
entity?.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION],
entity?.metadata.annotations?.[LOCATION_ANNOTATION],
].filter(Boolean);
return !isGithubDeploymentsAvailable(entity) ? (
<MissingAnnotationEmptyState annotation={GITHUB_PROJECT_SLUG_ANNOTATION} />
@@ -76,6 +86,7 @@ export const GithubDeploymentsCard = ({
entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] || ''
}
last={last || 10}
host={host}
columns={columns || GithubDeploymentsTable.defaultDeploymentColumns}
/>
);
@@ -13,15 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { QueryResponse } from '../api';
export const entityStub = {
export const entityStub: { entity: Entity } = {
entity: {
metadata: {
namespace: 'default',
annotations: {
'github.com/project-slug': 'org/repo',
},
annotations: {},
name: 'sample-service',
description: 'Sample service',
uid: 'g0h33dd9-56h7-835b-b63v-7x5da3j64851',
+7 -3
View File
@@ -19,6 +19,7 @@ import {
createPlugin,
githubAuthApiRef,
} from '@backstage/core';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { githubDeploymentsApiRef, GithubDeploymentsApiClient } from './api';
export const githubDeploymentsPlugin = createPlugin({
@@ -26,9 +27,12 @@ export const githubDeploymentsPlugin = createPlugin({
apis: [
createApiFactory({
api: githubDeploymentsApiRef,
deps: { githubAuthApi: githubAuthApiRef },
factory: ({ githubAuthApi }) =>
new GithubDeploymentsApiClient({ githubAuthApi }),
deps: {
scmIntegrationsApi: scmIntegrationsApiRef,
githubAuthApi: githubAuthApiRef,
},
factory: ({ scmIntegrationsApi, githubAuthApi }) =>
new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }),
}),
],
});