diff --git a/.changeset/khaki-hounds-think.md b/.changeset/khaki-hounds-think.md new file mode 100644 index 0000000000..9c7145fc89 --- /dev/null +++ b/.changeset/khaki-hounds-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Github Workflow Runs UI is modified to show in optional Card view instead of table, with branch selection option diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index b94862db2b..d0b5ceb54d 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -92,3 +92,19 @@ integrations: - There is a limit of 100 apps for one OAuth client/token pair - The OAuth application must be at the GitHub organization level in order to display the workflows. If you do not see any workflows, confirm the OAuth application was created in the organization and not a specific user account. + +## Optional Workflow Runs Card View + +Github Workflow Runs optional UI to show in Card view instead of table, with branch selection option + +```tsx + +// You can add the tab to any number of pages, the service page is shown as an +// example given here +const serviceEntityPage = ( + + {/* other tabs... */} + + + +``` diff --git a/plugins/github-actions/api-report.md b/plugins/github-actions/api-report.md index 887556f431..7a7eb4e972 100644 --- a/plugins/github-actions/api-report.md +++ b/plugins/github-actions/api-report.md @@ -16,6 +16,17 @@ import { RestEndpointMethodTypes } from '@octokit/rest'; import { RouteRef } from '@backstage/core-plugin-api'; import { ScmAuthApi } from '@backstage/integration-react'; +// @public (undocumented) +export type Branch = { + name: string; +}; + +// @public (undocumented) +export type Branches = { + default_branch: string; + branches: Branch[]; +}; + // @public (undocumented) export enum BuildStatus { // (undocumented) @@ -29,7 +40,7 @@ export enum BuildStatus { } // @public (undocumented) -export const EntityGithubActionsContent: () => JSX_2.Element; +export const EntityGithubActionsContent: (props: RouterProps) => JSX_2.Element; // @public (undocumented) export const EntityLatestGithubActionRunCard: (props: { @@ -106,6 +117,21 @@ export type GithubActionsApi = { }) => Promise< RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data'] >; + listBranches: (options: { + hostname?: string; + owner: string; + repo: string; + page: number; + }) => Promise< + RestEndpointMethodTypes['repos']['listBranches']['response']['data'] + >; + getDefaultBranch: (options: { + hostname?: string; + owner: string; + repo: string; + }) => Promise< + RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch'] + >; }; // @public (undocumented) @@ -124,6 +150,14 @@ export class GithubActionsClient implements GithubActionsApi { RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data'] >; // (undocumented) + getDefaultBranch(options: { + hostname?: string; + owner: string; + repo: string; + }): Promise< + RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch'] + >; + // (undocumented) getWorkflow(options: { hostname?: string; owner: string; @@ -142,6 +176,15 @@ export class GithubActionsClient implements GithubActionsApi { RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data'] >; // (undocumented) + listBranches(options: { + hostname?: string; + owner: string; + repo: string; + page?: number; + }): Promise< + RestEndpointMethodTypes['repos']['listBranches']['response']['data'] + >; + // (undocumented) listJobsForWorkflowRun(options: { hostname?: string; owner: string; @@ -226,7 +269,13 @@ export const RecentWorkflowRunsCard: (props: { }) => React_2.JSX.Element; // @public (undocumented) -export const Router: () => React_2.JSX.Element; +export const Router: (props: RouterProps) => React_2.JSX.Element; + +// @public (undocumented) +export interface RouterProps { + // (undocumented) + view?: 'cards' | 'table'; +} // @public (undocumented) export type Step = { diff --git a/plugins/github-actions/dev/index.tsx b/plugins/github-actions/dev/index.tsx index 153478ddbc..8321d33930 100644 --- a/plugins/github-actions/dev/index.tsx +++ b/plugins/github-actions/dev/index.tsx @@ -63,6 +63,12 @@ const mockGithubActionsApi: GithubActionsApi = { async reRunWorkflow() { return {} as any; }, + async listBranches() { + return {} as any; + }, + async getDefaultBranch() { + return {} as any; + }, }; createDevApp() diff --git a/plugins/github-actions/docs/card-view.png b/plugins/github-actions/docs/card-view.png new file mode 100644 index 0000000000..ef59c294d4 Binary files /dev/null and b/plugins/github-actions/docs/card-view.png differ diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index 14dbc3210e..6d3b25f93d 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -83,4 +83,21 @@ export type GithubActionsApi = { }) => Promise< RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data'] >; + + listBranches: (options: { + hostname?: string; + owner: string; + repo: string; + page: number; + }) => Promise< + RestEndpointMethodTypes['repos']['listBranches']['response']['data'] + >; + + getDefaultBranch: (options: { + hostname?: string; + owner: string; + repo: string; + }) => Promise< + RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch'] + >; }; diff --git a/plugins/github-actions/src/api/GithubActionsClient.ts b/plugins/github-actions/src/api/GithubActionsClient.ts index e427c343e2..734326e61b 100644 --- a/plugins/github-actions/src/api/GithubActionsClient.ts +++ b/plugins/github-actions/src/api/GithubActionsClient.ts @@ -174,4 +174,41 @@ export class GithubActionsClient implements GithubActionsApi { return workflow.data; } + + async listBranches(options: { + hostname?: string; + owner: string; + repo: string; + page?: number; + }): Promise< + RestEndpointMethodTypes['repos']['listBranches']['response']['data'] + > { + const { hostname, owner, repo, page = 0 } = options; + const octokit = await this.getOctokit(hostname); + const response = await octokit.rest.repos.listBranches({ + owner, + repo, + per_page: 100, + page, + }); + + return response.data; + } + + async getDefaultBranch(options: { + hostname?: string; + owner: string; + repo: string; + }): Promise< + RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch'] + > { + const { hostname, owner, repo } = options; + const octokit = await this.getOctokit(hostname); + const response = await octokit.rest.repos.get({ + owner, + repo, + }); + + return response.data.default_branch; + } } diff --git a/plugins/github-actions/src/api/types.ts b/plugins/github-actions/src/api/types.ts index 2e997d9bf7..394289149b 100644 --- a/plugins/github-actions/src/api/types.ts +++ b/plugins/github-actions/src/api/types.ts @@ -49,3 +49,19 @@ export enum BuildStatus { 'pending', 'running', } + +/** @public */ +export type Branch = { + name: string; +}; + +/** @public */ +export type Branches = { + default_branch: string; + branches: Branch[]; +}; + +/** @public */ +export interface RouterProps { + view?: 'cards' | 'table'; +} diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index e9a40f13f4..ff4e6bfa44 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -23,15 +23,18 @@ import { import { Routes, Route } from 'react-router-dom'; import { buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; +import { WorkflowRunsCard } from './WorkflowRunsCard'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './getProjectNameFromEntity'; +import { RouterProps } from '../api/types'; /** @public */ export const isGithubActionsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]); /** @public */ -export const Router = () => { +export const Router = (props: RouterProps) => { + const { view = 'table' } = props; const { entity } = useEntity(); if (!isGithubActionsAvailable(entity)) { @@ -39,14 +42,21 @@ export const Router = () => { ); } + + const workflowRunsComponent = + view === 'cards' ? ( + + ) : ( + + ); + return ( - } /> + } /> - ) ); }; diff --git a/plugins/github-actions/src/components/WorkflowRunsCard/WorkflowRunsCard.tsx b/plugins/github-actions/src/components/WorkflowRunsCard/WorkflowRunsCard.tsx new file mode 100644 index 0000000000..dc97720736 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunsCard/WorkflowRunsCard.tsx @@ -0,0 +1,412 @@ +/* + * Copyright 2020 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, { ChangeEvent, useEffect, useState } from 'react'; +import { + Typography, + Box, + IconButton, + Tooltip, + Button, + Chip, + ButtonGroup, + Grid, + makeStyles, + createStyles, + Theme, + TablePagination, + Select, + MenuItem, + TextField, + CircularProgress, +} from '@material-ui/core'; +import { + EmptyState, + Link, + MarkdownContent, + InfoCard, +} from '@backstage/core-components'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import RetryIcon from '@material-ui/icons/Replay'; +import SyncIcon from '@material-ui/icons/Sync'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { buildRouteRef } from '../../routes'; +import { getProjectNameFromEntity } from '../getProjectNameFromEntity'; +import { getHostnameFromEntity } from '../getHostnameFromEntity'; + +import { Alert, Color } from '@material-ui/lab'; +import { Entity } from '@backstage/catalog-model'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + overflow: 'visible', + position: 'relative', + margin: theme.spacing(4, 1, 1), + flex: '1', + minWidth: '0px', + }, + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'middle', + }, + bottomline: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: '-5px', + }, + pagination: { + width: '100%', + }, + }), +); + +type WorkflowRunsCardViewProps = { + runs?: WorkflowRun[]; + searchTerm: string; + loading: boolean; + onChangePageSize: (pageSize: number) => void; + onChangePage: (page: number) => void; + page: number; + total: number; + pageSize: number; + projectName: string; +}; + +const statusColors: Record = { + skipped: 'warning', + canceled: 'info', + timed_out: 'error', + failure: 'error', + success: 'success', +}; + +const matchesSearchTerm = (run: WorkflowRun, searchTerm: string) => { + const lowerCaseSearchTerm = searchTerm.toLocaleLowerCase(); + return ( + run.workflowName?.toLocaleLowerCase().includes(lowerCaseSearchTerm) || + run.source.branchName?.toLocaleLowerCase().includes(lowerCaseSearchTerm) || + run.status?.toLocaleLowerCase().includes(lowerCaseSearchTerm) || + run.id?.toLocaleLowerCase().includes(lowerCaseSearchTerm) + ); +}; + +export const WorkflowRunsCardView = ({ + runs, + searchTerm, + loading, + onChangePageSize, + onChangePage, + page, + total, + pageSize, +}: WorkflowRunsCardViewProps) => { + const classes = useStyles(); + const routeLink = useRouteRef(buildRouteRef); + + const filteredRuns = runs?.filter(run => matchesSearchTerm(run, searchTerm)); + + return ( + + {filteredRuns && runs?.length !== 0 ? ( + filteredRuns.map(run => ( + + + + + + + + + + {run.workflowName} + + + + + + + + + + + + {run.source.branchName && ( + + )} + + + + + } + /> +
+ {run.githubUrl && ( + + Workflow runs on GitHub{' '} + + + )} + + + + + + + +
+
+
+ +
+ )) + ) : ( + + {loading ? : 'No matching runs found.'} + + )} +
+ onChangePage(newPage)} + onRowsPerPageChange={event => + onChangePageSize(parseInt(event.target.value, 6)) + } + labelRowsPerPage="Workflows per page" + rowsPerPageOptions={[6, 12, 18, { label: 'All', value: -1 }]} + /> +
+
+ ); +}; + +type WorkflowRunsCardProps = { + entity: Entity; +}; + +const WorkflowRunsCardSearch = ({ + searchTerm, + handleSearch, + retry, +}: { + searchTerm: string; + handleSearch: (event: ChangeEvent) => void; + retry: () => void; +}) => { + return ( + <> + + + + + + + + + + + ); +}; + +export const WorkflowRunsCard = ({ entity }: WorkflowRunsCardProps) => { + const projectName = getProjectNameFromEntity(entity); + const hostname = getHostnameFromEntity(entity); + const [owner, repo] = (projectName ?? '/').split('/'); + const [branch, setBranch] = useState('default'); + const [runs, setRuns] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + + const handleSearch = (event: ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + const [ + { runs: runsData, branches, defaultBranch, ...cardProps }, + { retry, setPage, setPageSize }, + ] = useWorkflowRuns({ + hostname, + owner, + repo, + branch: branch === 'all' ? undefined : branch, + }); + + const githubHost = hostname || 'github.com'; + const hasNoRuns = !cardProps.loading && !runsData; + + const handleMenuChange = ( + event: ChangeEvent<{ name?: string; value: unknown }>, + ) => { + const selectedValue = event.target.value as string; + setBranch(selectedValue); + setPage(0); + retry(); + }; + + useEffect(() => { + setRuns(runsData); + }, [runsData, branch]); + + useEffect(() => { + setBranch(defaultBranch); + }, [defaultBranch]); + + return ( + + {hasNoRuns ? ( + + Create new Workflow + + } + /> + ) : ( + + + + {projectName} + + + + + + } + > + + + )} + + ); +}; + +export default WorkflowRunsCard; diff --git a/plugins/github-actions/src/components/WorkflowRunsCard/index.ts b/plugins/github-actions/src/components/WorkflowRunsCard/index.ts new file mode 100644 index 0000000000..77b4c85664 --- /dev/null +++ b/plugins/github-actions/src/components/WorkflowRunsCard/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 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 { WorkflowRunsCard, WorkflowRunsCardView } from './WorkflowRunsCard'; diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index 9ffbdad452..aacfabbe2f 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -17,6 +17,7 @@ import { useState } from 'react'; import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { githubActionsApiRef } from '../api/GithubActionsApi'; import { useApi, errorApiRef } from '@backstage/core-plugin-api'; +import { Branch } from '../api'; export type WorkflowRun = { workflowName?: string; @@ -41,12 +42,12 @@ export function useWorkflowRuns({ owner, repo, branch, - initialPageSize = 5, + initialPageSize = 6, }: { hostname?: string; owner: string; repo: string; - branch?: string; + branch?: string | undefined; initialPageSize?: number; }) { const api = useApi(githubActionsApiRef); @@ -56,6 +57,8 @@ export function useWorkflowRuns({ const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(initialPageSize); + const [branches, setBranches] = useState([]); + const [defaultBranch, setDefaultBranch] = useState(''); const { loading, @@ -63,6 +66,43 @@ export function useWorkflowRuns({ retry, error, } = useAsyncRetry(async () => { + const fetchedDefaultBranch = await api.getDefaultBranch({ + hostname, + owner, + repo, + }); + + setDefaultBranch(fetchedDefaultBranch); + let selectedBranch = branch; + if (branch === 'default') { + selectedBranch = fetchedDefaultBranch; + } + + const fetchBranches = async () => { + let next = true; + let iteratePage = 0; + const branchSet: Branch[] = []; + + while (next) { + const branchesData = await api.listBranches({ + hostname, + owner, + repo, + page: iteratePage, + }); + if (branchesData.length === 0) { + next = false; + } + iteratePage++; + branchSet.push(...branchesData); + } + + return branchSet; + }; + + const branchSet = await fetchBranches(); + setBranches(branchSet); + // GitHub API pagination count starts from 1 const workflowRunsData = await api.listWorkflowRuns({ hostname, @@ -70,7 +110,7 @@ export function useWorkflowRuns({ repo, pageSize, page: page + 1, - branch, + branch: selectedBranch, }); setTotal(workflowRunsData.total_count); // Transformation here @@ -88,7 +128,7 @@ export function useWorkflowRuns({ }); } catch (e) { errorApi.post( - new Error(`Failed to rerun the workflow: ${e.message}`), + new Error(`Failed to rerun the workflow: ${(e as Error).message}`), ); } }, @@ -115,6 +155,8 @@ export function useWorkflowRuns({ pageSize, loading, runs, + branches, + defaultBranch, projectName: `${owner}/${repo}`, total, error,