Add GoCD plugin

Signed-off-by: Julio Zynger <julio.zynger@soundcloud.com>
This commit is contained in:
Julio Zynger
2021-12-23 16:35:33 +01:00
parent a88401c76e
commit 3671ae9bcd
20 changed files with 1044 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { GoCdApi } from './gocdApi';
import { GoCdApiError, PipelineHistory } from './gocdApi.model';
import { DiscoveryApi } from '@backstage/core-plugin-api';
export class GoCdClientApi implements GoCdApi {
constructor(private readonly discoveryApi: DiscoveryApi) {}
async getPipelineHistory(
pipelineName: string,
): Promise<PipelineHistory | GoCdApiError> {
const baseUrl = await this.discoveryApi.getBaseUrl('proxy');
const pipelineHistoryResponse = await fetch(
`${baseUrl}/gocd/pipelines/${pipelineName}/history`,
);
return await pipelineHistoryResponse.json();
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
* 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 interface GoCdApiError {
message: string;
}
export interface PipelineHistory {
_links: Links;
pipelines: Pipeline[];
}
export interface Links {
next: Next;
}
export interface Next {
href: string;
}
export interface Pipeline {
name: string;
counter: number;
label: string;
natural_order?: number;
can_run?: boolean;
preparing_to_schedule?: boolean;
comment: string | null;
scheduled_date?: number;
build_cause?: BuildCause;
stages: Stage[];
}
export interface BuildCause {
trigger_message: string;
trigger_forced: boolean;
approver: string;
material_revisions: MaterialRevision[];
}
export interface MaterialRevision {
changed: boolean;
material: Material;
modifications: Modification[];
}
export interface Material {
name: string;
fingerprint: string;
type: string;
description: string;
}
export interface Modification {
revision: string;
modified_time: number;
user_name: string;
comment: string | null;
email_address: string | null;
}
export interface Stage {
result?: string;
status: string;
rerun_of_counter?: number | null;
name: string;
counter: string;
scheduled: boolean;
approval_type?: string | null;
approved_by?: string | null;
operate_permission?: boolean;
can_run?: boolean;
jobs: Job[];
}
export interface Job {
name: string;
scheduled_date?: number;
state: string;
result: string;
}
export enum GoCdBuildResultStatus {
running,
successful,
warning,
aborted,
error,
pending,
}
export interface GoCdBuildStageResult {
status: GoCdBuildResultStatus;
text: string;
}
export interface GoCdBuildResult {
id: number;
source: string;
stages: GoCdBuildStageResult[];
buildSlug: string;
message: string;
pipeline: string;
author: string | undefined;
commitHash: string;
triggerTime: number | undefined;
}
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { GoCdApiError, PipelineHistory } from './gocdApi.model';
export interface GoCdApi {
getPipelineHistory(
pipelineName: string,
): Promise<PipelineHistory | GoCdApiError>;
}
@@ -0,0 +1,53 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { GoCdBuildsComponent } from './GoCdBuildsComponent';
import { GoCdApi } from '../../api/gocdApi';
let entityValue: {
entity: { metadata: { annotations?: { [key: string]: string } } };
};
const mockApiClient: GoCdApi = {
getPipelineHistory: jest.fn(async () => ({
_links: { next: { href: 'some-href' } },
pipelines: [],
})),
};
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => {
return entityValue;
},
}));
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: () => mockApiClient,
}));
describe('GoCdArtifactsComponent', () => {
entityValue = { entity: { metadata: {} } };
const renderComponent = () => render(<GoCdBuildsComponent />);
it('should display an empty state if an app annotation is missing', async () => {
const rendered = renderComponent();
expect(await rendered.findByText('Missing Annotation')).toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
/*
* 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 React, { useState } from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Content,
ContentHeader,
MissingAnnotationEmptyState,
EmptyState,
Page,
} from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import { gocdApiRef } from '../../plugin';
import { GoCdBuildsTable } from '../GoCdBuildsTable/GoCdBuildsTable';
import { GoCdApiError, PipelineHistory } from '../../api/gocdApi.model';
import { Item, Select } from '../Select';
export const GOCD_APP_ANNOTATION = 'gocd.org/pipelines';
export const isGoCdAvailable = (entity: Entity): boolean =>
Boolean(entity.metadata.annotations?.[GOCD_APP_ANNOTATION]);
export const GoCdBuildsComponent = (): JSX.Element => {
const { entity } = useEntity();
const config = useApi(configApiRef);
const rawPipelines: string[] | undefined = (
entity.metadata.annotations?.[GOCD_APP_ANNOTATION] as string | undefined
)
?.split(',')
.map(p => p.trim());
const gocdApi = useApi(gocdApiRef);
const [selectedPipeline, setSelectedPipeline] = useState<string>(
rawPipelines ? rawPipelines[0] : '',
);
const {
value: pipelineHistory,
loading,
error,
} = useAsync(async (): Promise<PipelineHistory | GoCdApiError> => {
return await gocdApi.getPipelineHistory(selectedPipeline);
}, [selectedPipeline]);
const onSelectedPipelineChanged = (pipeline: string) => {
setSelectedPipeline(pipeline);
};
const getSelectionItems = (pipelines: string[]): Item[] => {
return pipelines.map(p => ({ label: p, value: p }));
};
function isError(
apiResult: PipelineHistory | GoCdApiError | undefined,
): apiResult is GoCdApiError {
return (apiResult as GoCdApiError)?.message !== undefined;
}
if (!rawPipelines) {
return <MissingAnnotationEmptyState annotation={GOCD_APP_ANNOTATION} />;
}
if (isError(pipelineHistory)) {
return (
<EmptyState
title="GoCD pipelines"
description={`Could not fetch pipelines defined for entity ${entity.metadata.name}. Error: ${pipelineHistory.message}`}
missing="content"
/>
);
}
if (!loading && !pipelineHistory) {
return (
<EmptyState
title="GoCD pipelines"
description={`We could not find pipelines defined for entity ${entity.metadata.name}.`}
missing="data"
/>
);
}
return (
<Page themeId="tool">
<Content noPadding>
<ContentHeader title={entity.metadata.name}>
<Select
value={selectedPipeline}
onChange={pipeline => onSelectedPipelineChanged(pipeline)}
label="Pipeline"
items={getSelectionItems(rawPipelines)}
/>
</ContentHeader>
<GoCdBuildsTable
goCdBaseUrl={config.getString('gocd.baseUrl')}
pipelineHistory={pipelineHistory}
loading={loading}
error={error}
/>
</Content>
</Page>
);
};
@@ -0,0 +1,20 @@
/*
* 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 {
GoCdBuildsComponent,
isGoCdAvailable,
GOCD_APP_ANNOTATION,
} from './GoCdBuildsComponent';
@@ -0,0 +1,264 @@
/*
* 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 React, { useState } from 'react';
import { Entity, getEntitySourceLocation } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Alert } from '@material-ui/lab';
import { Button } from '@material-ui/core';
import GitHubIcon from '@material-ui/icons/GitHub';
import {
GoCdBuildResult,
GoCdBuildResultStatus,
PipelineHistory,
Pipeline,
} from '../../api/gocdApi.model';
import { DateTime } from 'luxon';
import {
Table,
TableColumn,
Link,
SubvalueCell,
StatusOK,
StatusWarning,
StatusAborted,
StatusError,
StatusRunning,
StatusPending,
} from '@backstage/core-components';
type GoCdBuildsProps = {
goCdBaseUrl: string;
pipelineHistory: PipelineHistory | undefined;
loading: boolean;
error: Error | undefined;
};
const renderTrigger = (build: GoCdBuildResult): React.ReactNode => {
const subvalue = (
<>
{build.pipeline}
<br />
{build.author}
</>
);
return <SubvalueCell value={build.message} subvalue={subvalue} />;
};
const renderStages = (build: GoCdBuildResult): React.ReactNode => {
return build.stages.map(s => {
switch (s.status) {
case GoCdBuildResultStatus.successful: {
return (
<>
<StatusOK>{s.text}</StatusOK>
<br />
<br />
</>
);
}
case GoCdBuildResultStatus.error: {
return (
<>
<StatusError>{s.text}</StatusError>
<br />
<br />
</>
);
}
case GoCdBuildResultStatus.running: {
return (
<>
<StatusRunning>{s.text}</StatusRunning>
<br />
<br />
</>
);
}
case GoCdBuildResultStatus.aborted: {
return (
<>
<StatusAborted>{s.text}</StatusAborted>
<br />
<br />
</>
);
}
case GoCdBuildResultStatus.pending: {
return (
<>
<StatusPending>{s.text}</StatusPending>
<br />
<br />
</>
);
}
default: {
return (
<>
<StatusWarning>{s.text}</StatusWarning>
<br />
<br />
</>
);
}
}
});
};
const renderSource = (build: GoCdBuildResult): React.ReactNode => {
return (
<Button
href={build.source}
target="_blank"
rel="noreferrer"
startIcon={<GitHubIcon />}
>
{build.commitHash}
</Button>
);
};
const renderId = (
goCdBaseUrl: string,
build: GoCdBuildResult,
): React.ReactNode => {
const goCdBuildUrl = `${goCdBaseUrl}/go/pipelines/value_stream_map/${build.buildSlug}`;
return (
<SubvalueCell
value={
<Link to={goCdBuildUrl} target="_blank" rel="noreferrer">
{build.id}
</Link>
}
subvalue={
build.triggerTime &&
DateTime.fromMillis(build.triggerTime).toLocaleString(
DateTime.DATETIME_MED,
)
}
/>
);
};
const renderError = (error: Error): JSX.Element => {
return <Alert severity="error">{error.message}</Alert>;
};
const toStageStatus = (status: string): GoCdBuildResultStatus => {
switch (status.toLocaleLowerCase('en-US')) {
case 'passed':
return GoCdBuildResultStatus.successful;
case 'failed':
return GoCdBuildResultStatus.error;
case 'aborted':
return GoCdBuildResultStatus.aborted;
case 'building':
return GoCdBuildResultStatus.running;
case 'pending':
return GoCdBuildResultStatus.pending;
default:
return GoCdBuildResultStatus.aborted;
}
};
const toBuildResults = (
entity: Entity,
builds: Pipeline[],
): GoCdBuildResult[] | undefined => {
// TODO(julioz): What if not git 'type'?
const entitySourceLocation =
getEntitySourceLocation(entity).target.split('/tree')[0];
return builds.map(build => ({
id: build.counter,
source: `${entitySourceLocation}/commit/${build.build_cause?.material_revisions[0]?.modifications[0].revision}`,
stages: build.stages.map(s => ({
status: toStageStatus(s.status),
text: s.name,
})),
buildSlug: `${build.name}/${build.counter}`,
message:
build.build_cause?.material_revisions[0]?.modifications[0].comment ?? '',
pipeline: build.name,
author:
build.build_cause?.material_revisions[0]?.modifications[0].user_name,
commitHash: build.label,
triggerTime: build.scheduled_date,
}));
};
export const GoCdBuildsTable = (props: GoCdBuildsProps): JSX.Element => {
const { pipelineHistory, loading, error } = props;
const { entity } = useEntity();
const [, setSelectedSearchTerm] = useState<string>('');
const columns: TableColumn<GoCdBuildResult>[] = [
{
title: 'ID',
field: 'id',
highlight: true,
render: build => renderId(props.goCdBaseUrl, build),
},
{
title: 'Trigger',
customFilterAndSearch: (query, row: GoCdBuildResult) =>
`${row.message} ${row.pipeline} ${row.author}`
.toLocaleUpperCase('en-US')
.includes(query.toLocaleUpperCase('en-US')),
field: 'message',
highlight: true,
render: build => renderTrigger(build),
},
{
title: 'Stages',
field: 'status',
render: build => renderStages(build),
},
{
title: 'Source',
field: 'source',
render: build => renderSource(build),
},
];
return (
<>
{error && renderError(error)}
{!error && (
<Table
title="GoCD builds"
isLoading={loading}
options={{
search: true,
searchAutoFocus: true,
debounceInterval: 800,
paging: false,
showFirstLastPageButtons: false,
pageSize: 10,
}}
columns={columns}
data={
pipelineHistory
? toBuildResults(entity, pipelineHistory.pipelines) || []
: []
}
onSearchChange={(searchTerm: string) =>
setSelectedSearchTerm(searchTerm)
}
/>
)}
</>
);
};
@@ -0,0 +1,82 @@
/*
* 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 FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import React from 'react';
export type Item = {
label: string;
value: string | number;
};
const useStyles = makeStyles((theme: Theme) =>
createStyles({
formControl: {
margin: theme.spacing(1),
minWidth: 120,
},
selectEmpty: {
marginTop: theme.spacing(2),
},
}),
);
type SelectComponentProps = {
value: string;
items: Item[];
label: string;
onChange: (value: string) => void;
};
const renderItems = (items: Item[]) => {
return items.map(item => {
return (
<MenuItem value={item.value} key={item.label}>
{item.label}
</MenuItem>
);
});
};
export const SelectComponent = ({
value,
items,
label,
onChange,
}: SelectComponentProps): JSX.Element => {
const classes = useStyles();
const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
const val = event.target.value as string;
onChange(val);
};
return (
<FormControl
variant="outlined"
className={classes.formControl}
disabled={items.length === 0}
>
<InputLabel>{label}</InputLabel>
<Select label={label} value={value} onChange={handleChange}>
{renderItems(items)}
</Select>
</FormControl>
);
};
@@ -0,0 +1,17 @@
/*
* 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 { SelectComponent as Select } from './Select';
export type { Item } from './Select';
+29
View File
@@ -0,0 +1,29 @@
/*
* 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 { gocdPlugin } from './plugin';
import { createComponentExtension } from '@backstage/core-plugin-api';
export const EntityGoCdContent = gocdPlugin.provide(
createComponentExtension({
name: 'EntityGoCdContent',
component: {
lazy: () =>
import('./components/GoCdBuildsComponent').then(
m => m.GoCdBuildsComponent,
),
},
}),
);
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 { gocdPlugin } from './plugin';
export { EntityGoCdContent } from './extensions';
export {
GOCD_APP_ANNOTATION,
isGoCdAvailable,
} from './components/GoCdBuildsComponent';
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { gocdPlugin } from './plugin';
describe('gocd', () => {
it('should export plugin', () => {
expect(gocdPlugin).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
/*
* 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 { GoCdClientApi } from './api/gocdApi.client';
import { GoCdApi } from './api/gocdApi';
import {
discoveryApiRef,
createApiRef,
createApiFactory,
createPlugin,
} from '@backstage/core-plugin-api';
export const gocdApiRef = createApiRef<GoCdApi>({
id: 'plugin.gocd.service',
description: 'Used by the GoCD plugin to retrieve information about builds.',
});
export const gocdPlugin = createPlugin({
id: 'gocd',
apis: [
createApiFactory({
api: gocdApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => {
return new GoCdClientApi(discoveryApi);
},
}),
],
});
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
title: 'gocd',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';