Merge branch 'master' of https://github.com/backstage/backstage into marley/7678-pull-request-dashboard

Signed-off-by: Marley Powell <Marley.Powell@exclaimer.com>
This commit is contained in:
Marley Powell
2021-11-18 11:25:35 +00:00
435 changed files with 12940 additions and 1307 deletions
@@ -16,6 +16,8 @@
import {
DashboardPullRequest,
PullRequest,
PullRequestOptions,
RepoBuild,
RepoBuildOptions,
Team,
@@ -36,6 +38,12 @@ export interface AzureDevOpsApi {
options?: RepoBuildOptions,
): Promise<{ items: RepoBuild[] }>;
getPullRequests(
projectName: string,
repoName: string,
options?: PullRequestOptions,
): Promise<{ items: PullRequest[] }>;
getDashboardPullRequests(
projectName: string,
): Promise<DashboardPullRequest[]>;
@@ -16,6 +16,8 @@
import {
DashboardPullRequest,
PullRequest,
PullRequestOptions,
RepoBuild,
RepoBuildOptions,
Team,
@@ -42,9 +44,35 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
repoName: string,
options?: RepoBuildOptions,
): Promise<{ items: RepoBuild[] }> {
const items = await this.get<RepoBuild[]>(
`repo-builds/${projectName}/${repoName}?top=${options?.top}`,
);
const queryString = new URLSearchParams();
if (options?.top) {
queryString.append('top', options.top.toString());
}
const urlSegment = `repo-builds/${encodeURIComponent(
projectName,
)}/${encodeURIComponent(repoName)}?${queryString}`;
const items = await this.get<RepoBuild[]>(urlSegment);
return { items };
}
public async getPullRequests(
projectName: string,
repoName: string,
options?: PullRequestOptions,
): Promise<{ items: PullRequest[] }> {
const queryString = new URLSearchParams();
if (options?.top) {
queryString.append('top', options.top.toString());
}
if (options?.status) {
queryString.append('status', options.status.toString());
}
const urlSegment = `pull-requests/${encodeURIComponent(
projectName,
)}/${encodeURIComponent(repoName)}?${queryString}`;
const items = await this.get<PullRequest[]>(urlSegment);
return { items };
}
@@ -0,0 +1,40 @@
/*
* 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 { SvgIcon, SvgIconProps } from '@material-ui/core';
import React from 'react';
export const AzurePipelinesIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 512 512">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
d="M461.81 53.81a4.4 4.4 0 00-3.3-3.39c-54.38-13.3-180 34.09-248.13 102.17a294.9 294.9 0 00-33.09 39.08c-21-1.9-42-.3-59.88 7.5-50.49 22.2-65.18 80.18-69.28 105.07a9 9 0 009.8 10.4l81.07-8.9a180.29 180.29 0 001.1 18.3 18.15 18.15 0 005.3 11.09l31.39 31.39a18.15 18.15 0 0011.1 5.3 179.91 179.91 0 0018.19 1.1l-8.89 81a9 9 0 0010.39 9.79c24.9-4 83-18.69 105.07-69.17 7.8-17.9 9.4-38.79 7.6-59.69a293.91 293.91 0 0039.19-33.09c68.38-68 115.47-190.86 102.37-247.95zM298.66 213.67a42.7 42.7 0 1160.38 0 42.65 42.65 0 01-60.38 0z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
d="M109.64 352a45.06 45.06 0 00-26.35 12.84C65.67 382.52 64 448 64 448s65.52-1.67 83.15-19.31A44.73 44.73 0 00160 402.32"
/>
</SvgIcon>
);
@@ -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 { AzurePipelinesIcon } from './AzurePipelinesIcon';
@@ -20,9 +20,51 @@ import React from 'react';
export const AzurePullRequestsIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 512 512">
<circle
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
cx="128"
cy="416"
r="48"
/>
<path
d="M160 95.1c0-44.18-35.82-80-80-80c-44.18 0-80 35.82-80 80C0 128.8 19.77 156.9 48 169.2V342.8C19.77 355.1 0 383.2 0 415.1c0 44.18 35.82 80 80 80c44.18 0 80-35.82 80-80c0-32.79-19.77-60.89-48-73.25V169.2C140.2 156.9 160 128.8 160 95.1zM80 439.1c-13.23 0-24-10.77-24-24s10.77-24 24-24c13.23 0 24 10.77 24 24S93.23 439.1 80 439.1zM80 119.1c-13.23 0-24-10.77-24-24s10.77-24 24-24c13.23 0 24 10.77 24 24S93.23 119.1 80 119.1zM464 342.8v-182.8c0-52.94-43.06-96-96-96H320v-48c0-6.219-3.594-11.84-9.219-14.5c-5.594-2.594-12.25-1.781-17.03 2.219l-96 80C194.1 86.75 192 91.25 192 95.1S194.1 105.2 197.8 108.3l96 80C296.7 190.7 300.3 191.1 304 191.1c2.312 0 4.625-.5 6.781-1.5C316.4 187.8 320 182.2 320 175.1v-48h48c17.66 0 32 14.34 32 32v182.8c-28.23 12.36-48 40.46-48 73.25c0 44.18 35.82 80 80 80c44.18 0 80-35.82 80-80C512 383.2 492.2 355.1 464 342.8zM432 439.1c-13.23 0-24-10.77-24-24s10.77-24 24-24c13.23 0 24 10.77 24 24S445.2 439.1 432 439.1z"
fill="currentColor"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
d="M128 144v224M288 160l-64-64 64-64"
/>
<circle
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
cx="128"
cy="96"
r="48"
/>
<circle
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
cx="384"
cy="416"
r="48"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
d="M240 96h84a60 60 0 0160 60v212"
/>
</SvgIcon>
);
@@ -56,7 +56,7 @@ const generateTestData = (rows = 10): RepoBuild[] => {
title: `backstage ci - 1.0.0-preview-${rows - i}`,
status,
result,
queueTime: new Date(Date.now() - i * 60000),
queueTime: new Date(Date.now() - i * 60000).toISOString(),
source: 'refs/heads/main',
link: '',
});
@@ -33,8 +33,10 @@ import {
TableColumn,
} from '@backstage/core-components';
import { AzurePipelinesIcon } from '../AzurePipelinesIcon';
import { DateTime } from 'luxon';
import React from 'react';
import { getDurationFromDates } from '../../utils/getDurationFromDates';
export const getBuildResultComponent = (result: number | undefined) => {
switch (result) {
@@ -144,12 +146,27 @@ const columns: TableColumn[] = [
</Box>
),
},
{
title: 'Duration',
field: 'queueTime',
width: 'auto',
render: (row: Partial<RepoBuild>) => (
<Box display="flex" alignItems="center">
<Typography>
{getDurationFromDates(row.startTime, row.finishTime)}
</Typography>
</Box>
),
},
{
title: 'Age',
field: 'queueTime',
width: 'auto',
render: (row: Partial<RepoBuild>) =>
DateTime.fromJSDate(row.queueTime ?? new Date()).toRelative(),
(row.queueTime
? DateTime.fromISO(row.queueTime)
: DateTime.now()
).toRelative(),
},
];
@@ -178,7 +195,13 @@ export const BuildTable = ({ items, loading, error }: BuildTableProps) => {
pageSize: 5,
showEmptyDataSourceMessage: !loading,
}}
title={`Builds (${items ? items.length : 0})`}
title={
<Box display="flex" alignItems="center">
<AzurePipelinesIcon style={{ fontSize: 30 }} />
<Box mr={1} />
Azure Pipelines - Builds ({items ? items.length : 0})
</Box>
}
data={items ?? []}
/>
);
@@ -0,0 +1,26 @@
/*
* 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 { PullRequestTable } from '../PullRequestTable/PullRequestTable';
import React from 'react';
export const EntityPageAzurePullRequests = ({
defaultLimit,
}: {
defaultLimit?: number;
}) => {
return <PullRequestTable defaultLimit={defaultLimit} />;
};
@@ -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 { EntityPageAzurePullRequests } from './EntityPageAzurePullRequests';
@@ -0,0 +1,57 @@
/*
* 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 { Button, ButtonGroup } from '@material-ui/core';
import { PullRequestStatus } from '@backstage/plugin-azure-devops-common';
import React from 'react';
export const PullRequestStatusButtonGroup = ({
status,
setStatus,
}: {
status: PullRequestStatus;
setStatus: (pullRequestStatus: PullRequestStatus) => void;
}) => {
return (
<ButtonGroup aria-label="outlined button group">
<Button
color={status === PullRequestStatus.Active ? 'primary' : 'default'}
onClick={() => {
setStatus(PullRequestStatus.Active);
}}
>
Active
</Button>
<Button
color={status === PullRequestStatus.Completed ? 'primary' : 'default'}
onClick={() => {
setStatus(PullRequestStatus.Completed);
}}
>
Completed
</Button>
<Button
color={status === PullRequestStatus.Abandoned ? 'primary' : 'default'}
onClick={() => {
setStatus(PullRequestStatus.Abandoned);
}}
>
Abandoned
</Button>
</ButtonGroup>
);
};
@@ -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 { PullRequestStatusButtonGroup } from './PullRequestStatusButtonGroup';
@@ -0,0 +1,139 @@
/*
* 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 { Box, Chip } from '@material-ui/core';
import {
Link,
ResponseErrorPanel,
Table,
TableColumn,
} from '@backstage/core-components';
import {
PullRequest,
PullRequestStatus,
} from '@backstage/plugin-azure-devops-common';
import React, { useState } from 'react';
import { AzurePullRequestsIcon } from '../AzurePullRequestsIcon';
import { DateTime } from 'luxon';
import { PullRequestStatusButtonGroup } from '../PullRequestStatusButtonGroup';
import { useEntity } from '@backstage/plugin-catalog-react';
import { usePullRequests } from '../../hooks/usePullRequests';
const columns: TableColumn[] = [
{
title: 'ID',
field: 'pullRequestId',
highlight: false,
width: 'auto',
},
{
title: 'Title',
field: 'title',
width: 'auto',
render: (row: Partial<PullRequest>) => (
<Box display="flex" alignItems="center">
<Link to={row.link ?? ''}>{row.title}</Link>
{row.isDraft && (
<Box paddingLeft={1}>
<Chip
label="Draft"
variant="outlined"
color="secondary"
size="small"
/>
</Box>
)}
</Box>
),
},
{
title: 'Source',
field: 'sourceRefName',
width: 'auto',
},
{
title: 'Target',
field: 'targetRefName',
width: 'auto',
},
{
title: 'Created By',
field: 'createdBy',
width: 'auto',
},
{
title: 'Created',
field: 'creationDate',
width: 'auto',
render: (row: Partial<PullRequest>) =>
(row.creationDate
? DateTime.fromISO(row.creationDate)
: DateTime.now()
).toRelative(),
},
];
type PullRequestTableProps = {
defaultLimit?: number;
};
export const PullRequestTable = ({ defaultLimit }: PullRequestTableProps) => {
const [pullRequestStatusState, setPullRequestStatusState] =
useState<PullRequestStatus>(PullRequestStatus.Active);
const { entity } = useEntity();
const { items, loading, error } = usePullRequests(
entity,
defaultLimit,
pullRequestStatusState,
);
if (error) {
return (
<div>
<ResponseErrorPanel error={error} />
</div>
);
}
return (
<Table
isLoading={loading}
columns={columns}
options={{
search: true,
paging: true,
pageSize: 5,
showEmptyDataSourceMessage: !loading,
}}
title={
<Box display="flex" alignItems="center">
<AzurePullRequestsIcon style={{ fontSize: 30 }} />
<Box mr={1} />
Azure Repos - Pull Requests ({items ? items.length : 0})
<Box position="absolute" right={320} top={20}>
<PullRequestStatusButtonGroup
status={pullRequestStatusState}
setStatus={setPullRequestStatusState}
/>
</Box>
</Box>
}
data={items ?? []}
/>
);
};
@@ -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 { PullRequestTable } from './PullRequestTable';
@@ -0,0 +1,58 @@
/*
* 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 {
PullRequest,
PullRequestOptions,
PullRequestStatus,
} from '@backstage/plugin-azure-devops-common';
import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
export function usePullRequests(
entity: Entity,
defaultLimit?: number,
requestedStatus?: PullRequestStatus,
): {
items?: PullRequest[];
loading: boolean;
error?: Error;
} {
const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP;
const status = requestedStatus ?? PullRequestStatus.Active;
const options: PullRequestOptions = {
top,
status,
};
const api = useApi(azureDevOpsApiRef);
const { project, repo } = useProjectRepoFromEntity(entity);
const { value, loading, error } = useAsync(() => {
return api.getPullRequests(project, repo, options);
}, [api, project, repo, top, status]);
return {
items: value?.items,
loading,
error,
};
}
+1
View File
@@ -17,6 +17,7 @@
export {
azureDevOpsPlugin,
EntityAzurePipelinesContent,
EntityAzurePullRequestsContent,
isAzureDevOpsAvailable,
AzurePullRequestsPage,
} from './plugin';
+12 -4
View File
@@ -17,6 +17,7 @@
import {
azurePipelinesEntityContentRouteRef,
azurePullRequestDashboardRouteRef,
azurePullRequestsEntityContentRouteRef,
} from './routes';
import {
createApiFactory,
@@ -44,10 +45,6 @@ export const azureDevOpsPlugin = createPlugin({
new AzureDevOpsClient({ discoveryApi, identityApi }),
}),
],
routes: {
azurePullRequestDashboard: azurePullRequestDashboardRouteRef,
azurePipelinesEntityContent: azurePipelinesEntityContentRouteRef,
},
});
export const AzurePullRequestsPage = azureDevOpsPlugin.provide(
@@ -69,3 +66,14 @@ export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide(
mountPoint: azurePipelinesEntityContentRouteRef,
}),
);
export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide(
createRoutableExtension({
name: 'EntityAzurePullRequestsContent',
component: () =>
import('./components/EntityPageAzurePullRequests').then(
m => m.EntityPageAzurePullRequests,
),
mountPoint: azurePullRequestsEntityContentRouteRef,
}),
);
+6 -2
View File
@@ -17,10 +17,14 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const azurePullRequestDashboardRouteRef = createRouteRef({
id: 'azure-pull-request-dashboard',
path: '',
title: 'Azure Pull Requests',
});
export const azurePipelinesEntityContentRouteRef = createRouteRef({
title: 'Azure Pipelines Entity Content',
id: 'azure-pipelines-entity-content',
});
export const azurePullRequestsEntityContentRouteRef = createRouteRef({
id: 'azure-pull-requests-entity-content',
});
@@ -0,0 +1,67 @@
/*
* 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 { getDurationFromDates } from './getDurationFromDates';
describe('getDurationFromDates', () => {
beforeAll(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2021-10-15T09:45:25.0000000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
describe('getDurationFromDates with valid startTime and valid finishTime', () => {
it('should return empty result', () => {
const startTime = '2021-10-15T09:30:00.0000000Z';
const finishTime = '2021-10-15T11:00:00.0000000Z';
const result = getDurationFromDates(startTime, finishTime);
expect(result).toEqual('1h, 30m');
});
});
describe('getDurationFromDates with valid startTime and undefined finishTime', () => {
it('should return empty result', () => {
const startTime = '2021-10-15T09:30:00.0000000Z';
const result = getDurationFromDates(startTime, undefined);
expect(result).toEqual('15m, 25s');
});
});
describe('getDurationFromDates with undefined startTime and valid finishTime', () => {
it('should return empty result', () => {
const finishTime = '2021-10-15T11:00:00.0000000Z';
const result = getDurationFromDates(undefined, finishTime);
expect(result).toEqual('');
});
});
describe('getDurationFromDates with undefined startTime and undefined finishTime', () => {
it('should return empty result', () => {
const result = getDurationFromDates(undefined, undefined);
expect(result).toEqual('');
});
});
});
@@ -0,0 +1,56 @@
/*
* 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 { DateTime, Interval } from 'luxon';
import humanizeDuration from 'humanize-duration';
export const getDurationFromDates = (
startTime?: string,
finishTime?: string,
): string => {
if (!startTime || (!startTime && !finishTime)) {
return '';
}
const start = DateTime.fromISO(startTime);
const finish = finishTime ? DateTime.fromISO(finishTime) : DateTime.now();
const formatted = Interval.fromDateTimes(start, finish)
.toDuration()
.valueOf();
const shortEnglishHumanizer = humanizeDuration.humanizer({
language: 'shortEn',
languages: {
shortEn: {
y: () => 'y',
mo: () => 'mo',
w: () => 'w',
d: () => 'd',
h: () => 'h',
m: () => 'm',
s: () => 's',
ms: () => 'ms',
},
},
});
return shortEnglishHumanizer(formatted, {
largest: 2,
round: true,
spacer: '',
});
};