diff --git a/.changeset/smooth-vans-travel.md b/.changeset/smooth-vans-travel.md new file mode 100644 index 0000000000..0038c35e8e --- /dev/null +++ b/.changeset/smooth-vans-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-deployments': patch +--- + +Support for GitHub Enterprise with GithubDeploymentsPlugin diff --git a/plugins/github-deployments/README.md b/plugins/github-deployments/README.md index 8284ceba56..3c5435b4a6 100644 --- a/plugins/github-deployments/README.md +++ b/plugins/github-deployments/README.md @@ -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' +``` diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 1086fc0d17..df85cb2a55 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -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", diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts index 7496370bc9..23a381f771 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -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; + listDeployments(params: QueryParams): Promise; } export const githubDeploymentsApiRef = createApiRef({ @@ -45,6 +81,7 @@ export const githubDeploymentsApiRef = createApiRef({ 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 { + async listDeployments(params: QueryParams): Promise { + 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() || []; } diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx index f128e6274b..bec60c47bc 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -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( + + + , + ); + + 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( - - - , - ); - - 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( + + + , + ); + + 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( + + + , + ); + + 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); + }); + }); }); }); diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx index 073f78af2e..a13c205084 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx @@ -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[]; + 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[]; }) => { const { entity } = useEntity(); + const [host] = [ + entity?.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION], + entity?.metadata.annotations?.[LOCATION_ANNOTATION], + ].filter(Boolean); return !isGithubDeploymentsAvailable(entity) ? ( @@ -76,6 +86,7 @@ export const GithubDeploymentsCard = ({ entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] || '' } last={last || 10} + host={host} columns={columns || GithubDeploymentsTable.defaultDeploymentColumns} /> ); diff --git a/plugins/github-deployments/src/mocks/mocks.ts b/plugins/github-deployments/src/mocks/mocks.ts index c0afb325db..b2a75ac7b0 100644 --- a/plugins/github-deployments/src/mocks/mocks.ts +++ b/plugins/github-deployments/src/mocks/mocks.ts @@ -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', diff --git a/plugins/github-deployments/src/plugin.ts b/plugins/github-deployments/src/plugin.ts index 3707b1cf68..943d5e0bc6 100644 --- a/plugins/github-deployments/src/plugin.ts +++ b/plugins/github-deployments/src/plugin.ts @@ -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 }), }), ], });