diff --git a/workspaces/github-pull-requests-board/.changeset/fresh-games-crash.md b/workspaces/github-pull-requests-board/.changeset/fresh-games-crash.md new file mode 100644 index 000000000..031c6851d --- /dev/null +++ b/workspaces/github-pull-requests-board/.changeset/fresh-games-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage-community/plugin-github-pull-requests-board': patch +--- + +Decouple entities from the board logic for reuse the board on other places diff --git a/workspaces/github-pull-requests-board/app-config.yaml b/workspaces/github-pull-requests-board/app-config.yaml new file mode 100644 index 000000000..361690740 --- /dev/null +++ b/workspaces/github-pull-requests-board/app-config.yaml @@ -0,0 +1,6 @@ +app: + title: Example App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7007 diff --git a/workspaces/github-pull-requests-board/package.json b/workspaces/github-pull-requests-board/package.json index b28bb99fa..5249dd898 100644 --- a/workspaces/github-pull-requests-board/package.json +++ b/workspaces/github-pull-requests-board/package.json @@ -6,6 +6,7 @@ "node": "18 || 20 || 22" }, "scripts": { + "start": "backstage-cli repo start", "tsc": "tsc", "tsc:full": "tsc --skipLibCheck false --incremental false", "build:all": "backstage-cli repo build --all", diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/dev/index.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/dev/index.tsx new file mode 100644 index 000000000..4bea22862 --- /dev/null +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/dev/index.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 2025 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 { Entity } from '@backstage/catalog-model'; +import { createDevApp } from '@backstage/dev-utils'; +import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; +import { Content, Header, HeaderLabel, Page } from '@backstage/core-components'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; + +import Wifi from '@material-ui/icons/WifiSharp'; +import OfflineIcon from '@material-ui/icons/WifiOff'; + +import { EntityTeamPullRequestsContent } from '../src'; +import { githubAuthApiRef } from '@backstage/frontend-plugin-api'; +import React from 'react'; + +const GITHUB_PULL_REQUESTS_ANNOTATION = 'github.com/project-slug'; +const GITHUB_USER_LOGIN_ANNOTATION = 'github.com/user-login'; + +const teamEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'engineering', + }, + spec: { + type: 'team', + }, + relations: [ + { + type: 'hasMember', + targetRef: 'user:default/user1', + }, + { + type: 'hasMember', + targetRef: 'user:default/user2', + }, + ], +}; +const teamMembers: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user1', + annotations: { + [GITHUB_USER_LOGIN_ANNOTATION]: 'Sarabadu', + }, + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/engineering', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user2', + annotations: { + [GITHUB_USER_LOGIN_ANNOTATION]: 'awanlin', + }, + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/engineering', + }, + ], + }, +]; + +const components: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'repo1', + annotations: { + [GITHUB_PULL_REQUESTS_ANNOTATION]: 'backstage/community-plugins', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'group:engineering', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/engineering', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'repo2', + annotations: { + [GITHUB_PULL_REQUESTS_ANNOTATION]: 'backstage/backstage', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'group:engineering', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/engineering', + }, + ], + }, +]; +const catalogApiMockImp = catalogApiMock({ + entities: [teamEntity, ...teamMembers, ...components], +}); + +const githubMockApi = { + getAccessToken: async () => { + // This is only here to make been able to locally test this plugin without having to + // setup a backend app + return 'mocked-token'; + }, +} as typeof githubAuthApiRef.T; + +createDevApp() + // .registerPlugin(githubPullRequestsBoardPlugin) + .addPage({ + element: ( + + + +
+ +
+ + + +
+
+
+ ), + title: 'Entity Todo Content', + icon: OfflineIcon, + }) + .addPage({ + element: ( + + + +
+ +
+ + + +
+
+
+ ), + title: 'Live Pull Requests Board', + icon: Wifi, + }) + + .render(); diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/package.json b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/package.json index cf4ffc4d7..879010d75 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/package.json +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/package.json @@ -72,6 +72,7 @@ }, "devDependencies": { "@backstage/cli": "^0.33.1", + "@backstage/dev-utils": "^1.1.12", "@backstage/frontend-test-utils": "^0.3.4", "@backstage/test-utils": "^1.7.10", "@testing-library/dom": "^10.0.0", diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/report.api.md b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/report.api.md index 285e6f381..52dc55a0c 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/report.api.md +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/report.api.md @@ -5,7 +5,9 @@ ```ts /// +import { Entity } from '@backstage/catalog-model'; import { JSX as JSX_2 } from 'react'; +import { default as React_2 } from 'react'; // @public (undocumented) export const EntityTeamPullRequestsCard: ( @@ -29,5 +31,17 @@ export interface EntityTeamPullRequestsContentProps { pullRequestLimit?: number; } +// @public +export const PullRequestsBoard: ( + props: PullRequestsBoardProps, +) => React_2.JSX.Element; + +// @public (undocumented) +export interface PullRequestsBoardProps { + entities: Entity[]; + // (undocumented) + pullRequestLimit?: number; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.test.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.test.tsx index c839d72fe..74cbfefa9 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.test.tsx +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.test.tsx @@ -32,6 +32,21 @@ jest.mock('../../hooks/useUserRepositoriesAndTeam', () => { }; }); +jest.mock('@backstage/plugin-catalog-react', () => { + return { + useEntity: () => { + return { + entity: { + metadata: { + name: 'test-entity', + namespace: 'default', + }, + }, + }; + }, + }; +}); + jest.mock('../../hooks/usePullRequestsByTeam', () => { const buildPullRequest = ({ prTitle, diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.tsx index ffda1c4a3..720959815 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.tsx +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsCard/EntityTeamPullRequestsCard.tsx @@ -30,6 +30,7 @@ import { shouldDisplayCard } from '../../utils/functions'; import { DraftPrIcon } from '../icons/DraftPr'; import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam'; import UnarchiveIcon from '@material-ui/icons/Unarchive'; +import { useEntity } from '@backstage/plugin-catalog-react'; /** @public */ export interface EntityTeamPullRequestsCardProps { @@ -39,12 +40,13 @@ export interface EntityTeamPullRequestsCardProps { const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => { const { pullRequestLimit } = props; const [infoCardFormat, setInfoCardFormat] = useState([]); + const { entity: teamEntity } = useEntity(); const { loading: loadingReposAndTeam, repositories, teamMembers, teamMembersOrganization, - } = useUserRepositoriesAndTeam(); + } = useUserRepositoriesAndTeam(teamEntity); const { loading: loadingPRs, pullRequests, diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.test.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.test.tsx index 9580b820b..1e0002f2d 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.test.tsx +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.test.tsx @@ -32,6 +32,21 @@ jest.mock('../../hooks/useUserRepositoriesAndTeam', () => { }; }); +jest.mock('@backstage/plugin-catalog-react', () => { + return { + useEntity: () => { + return { + entity: { + metadata: { + name: 'test-entity', + namespace: 'default', + }, + }, + }; + }, + }; +}); + jest.mock('../../hooks/usePullRequestsByTeam', () => { const buildPullRequest = ({ prTitle, diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.tsx index a0292881b..3645441e2 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.tsx +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/EntityTeamPullRequestsContent/EntityTeamPullRequestsContent.tsx @@ -13,21 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid, Typography } from '@material-ui/core'; -import PeopleIcon from '@material-ui/icons/People'; -import { Progress, InfoCard } from '@backstage/core-components'; -import { InfoCardHeader } from '../InfoCardHeader'; -import { PullRequestBoardOptions } from '../PullRequestBoardOptions'; -import { Wrapper } from '../Wrapper'; -import { PullRequestCard } from '../PullRequestCard'; -import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam'; -import { PRCardFormating } from '../../utils/types'; -import { shouldDisplayCard } from '../../utils/functions'; -import { DraftPrIcon } from '../icons/DraftPr'; -import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam'; -import UnarchiveIcon from '@material-ui/icons/Unarchive'; +import React from 'react'; +import PullRequestsBoard from '../PullRequestsBoard'; +import { useEntity } from '@backstage/plugin-catalog-react'; /** @public */ export interface EntityTeamPullRequestsContentProps { @@ -38,114 +27,14 @@ const EntityTeamPullRequestsContent = ( props: EntityTeamPullRequestsContentProps, ) => { const { pullRequestLimit } = props; - const [infoCardFormat, setInfoCardFormat] = useState([]); - const { - loading: loadingReposAndTeam, - repositories, - teamMembers, - teamMembersOrganization, - } = useUserRepositoriesAndTeam(); - const { - loading: loadingPRs, - pullRequests, - refreshPullRequests, - } = usePullRequestsByTeam( - repositories, - teamMembers, - teamMembersOrganization, - pullRequestLimit, + const { entity: teamEntity } = useEntity(); + + return ( + ); - - const header = ( - - setInfoCardFormat(newFormats)} - value={infoCardFormat} - options={[ - { - icon: , - value: 'team', - ariaLabel: 'Show PRs from your team', - }, - { - icon: , - value: 'draft', - ariaLabel: 'Show draft PRs', - }, - { - icon: , - value: 'archivedRepo', - ariaLabel: 'Show archived repos', - }, - ]} - /> - - ); - - const getContent = () => { - if (loadingReposAndTeam || loadingPRs) { - return ; - } - - return ( - - {pullRequests.length ? ( - pullRequests.map(({ title: columnTitle, content }) => ( - - {columnTitle} - {content.map( - ( - { - id, - title, - createdAt, - lastEditedAt, - author, - url, - latestReviews, - commits, - repository, - isDraft, - labels, - }, - index, - ) => - shouldDisplayCard( - repository, - author, - repositories, - teamMembers, - infoCardFormat, - isDraft, - ) && ( - - ), - )} - - )) - ) : ( - - No pull requests found - - )} - - ); - }; - - return {getContent()}; }; export default EntityTeamPullRequestsContent; diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.test.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.test.tsx new file mode 100644 index 000000000..7751d341c --- /dev/null +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.test.tsx @@ -0,0 +1,346 @@ +/* + * Copyright 2025 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 PullRequestsBoard from './PullRequestsBoard'; +import { PullRequestsColumn, Status } from '../../utils/types'; +import { render } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; + +const mockEntities = [ + { + apiVersion: 'v1', + kind: 'Group', + metadata: { + name: 'test-team', + namespace: 'default', + }, + spec: { + type: 'team', + }, + }, +]; + +jest.mock('../../hooks/useUserRepositoriesAndTeam', () => { + return { + useUserRepositoriesAndTeam: () => { + return { + loading: false, + repositories: ['team-login/team-repo'], + teamMembers: ['team-member'], + teamMembersOrganization: 'test-org', + }; + }, + }; +}); + +jest.mock('../../hooks/usePullRequestsByTeam', () => { + const buildPullRequest = ({ + prTitle, + authorLogin, + repoName, + isDraft, + isArchived, + status, + }: { + prTitle: string; + authorLogin: string; + repoName: string; + isDraft: boolean; + isArchived: boolean; + status: Status; + }) => { + return { + id: 'id', + title: prTitle, + url: 'url', + lastEditedAt: 'last-edited-at', + latestReviews: { + nodes: [], + }, + mergeable: true, + state: 'state', + reviewDecision: null, + createdAt: 'created-at', + repository: { + name: repoName, + owner: { + login: 'team-login', + }, + isArchived: isArchived, + }, + labels: { + nodes: [], + }, + commits: { + nodes: [status], + }, + isDraft: isDraft, + author: { + login: authorLogin, + avatarUrl: 'avatar-url', + id: 'id', + email: 'email', + name: 'name', + }, + }; + }; + + const pullRequests: PullRequestsColumn[] = [ + { + title: 'column', + content: [ + buildPullRequest({ + prTitle: 'non-team-non-draft-non-archive', + authorLogin: 'non-team-member', + repoName: 'team-repo', + isDraft: false, + isArchived: false, + status: { + commit: { + statusCheckRollup: null, + }, + }, + }), + buildPullRequest({ + prTitle: 'non-team-non-draft-is-archive', + authorLogin: 'non-team-member', + repoName: 'team-repo', + isDraft: false, + isArchived: true, + status: { + commit: { + statusCheckRollup: { + state: 'FAILURE', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'non-team-is-draft-non-archive', + authorLogin: 'non-team-member', + repoName: 'team-repo', + isDraft: true, + isArchived: false, + status: { + commit: { + statusCheckRollup: { + state: 'FAILURE', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'non-team-is-draft-is-archive', + authorLogin: 'non-team-member', + repoName: 'team-repo', + isDraft: true, + isArchived: true, + status: { + commit: { + statusCheckRollup: { + state: 'SUCCESS', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'is-team-non-draft-non-archive', + authorLogin: 'team-member', + repoName: 'non-team-repo', + isDraft: false, + isArchived: false, + status: { + commit: { + statusCheckRollup: { + state: 'FAILURE', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'is-team-non-draft-is-archive', + authorLogin: 'team-member', + repoName: 'non-team-repo', + isDraft: false, + isArchived: true, + status: { + commit: { + statusCheckRollup: { + state: 'FAILURE', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'is-team-is-draft-non-archive', + authorLogin: 'team-member', + repoName: 'non-team-repo', + isDraft: true, + isArchived: false, + status: { + commit: { + statusCheckRollup: { + state: 'FAILURE', + }, + }, + }, + }), + buildPullRequest({ + prTitle: 'is-team-is-draft-is-archive', + authorLogin: 'team-member', + repoName: 'non-team-repo', + isDraft: true, + isArchived: true, + status: { + commit: { + statusCheckRollup: { + state: 'SUCCESS', + }, + }, + }, + }), + ], + }, + ]; + + return { + usePullRequestsByTeam: () => { + return { + loading: false, + pullRequests: pullRequests, + refreshPullRequests: () => {}, + }; + }, + }; +}); + +describe('PullRequestsBoard', () => { + describe('non-team PRs', () => { + describe('non-draft PRs', () => { + it('should show non-team PRs for un-archived repos when archived option is not checked', async () => { + const { getByText, getAllByText, queryAllByTitle } = render( + , + ); + expect(getByText('non-team-non-draft-non-archive')).toBeInTheDocument(); + expect(getAllByText('team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(0); + expect(queryAllByTitle('Draft PR')).toHaveLength(0); + }); + + it('should show non-team PRs for archived repos when archived option is checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const archiveToggle = getByTitle('Show archived repos'); + fireEvent.click(archiveToggle); + expect(getByText('non-team-non-draft-is-archive')).toBeInTheDocument(); + expect(getAllByText('team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(1); + expect(queryAllByTitle('Draft PR')).toHaveLength(0); + }); + }); + + describe('draft PRs', () => { + it('should show draft non-team PRs for un-archived repos when archived option is not checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const draftToggle = getByTitle('Show draft PRs'); + fireEvent.click(draftToggle); + expect(getByText('non-team-is-draft-non-archive')).toBeInTheDocument(); + expect(getAllByText('team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(0); + expect(queryAllByTitle('Draft PR')).toHaveLength(1); + }); + + it('should show draft non-team PRs for archived repos when archived option is checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const draftToggle = getByTitle('Show draft PRs'); + fireEvent.click(draftToggle); + const archiveToggle = getByTitle('Show archived repos'); + fireEvent.click(archiveToggle); + expect(getByText('non-team-is-draft-is-archive')).toBeInTheDocument(); + expect(getAllByText('team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(1); + expect(queryAllByTitle('Draft PR')).toHaveLength(1); + }); + }); + }); + + describe('team PRs', () => { + describe('non-draft PRs', () => { + it('should show team PRs for un-archived repos when archived option is not checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const teamToggle = getByTitle('Show PRs from your team'); + fireEvent.click(teamToggle); + expect(getByText('is-team-non-draft-non-archive')).toBeInTheDocument(); + expect(getAllByText('non-team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(0); + expect(queryAllByTitle('Draft PR')).toHaveLength(0); + }); + + it('should show team PRs for archived repos when archived option is checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const teamToggle = getByTitle('Show PRs from your team'); + fireEvent.click(teamToggle); + const archiveToggle = getByTitle('Show archived repos'); + fireEvent.click(archiveToggle); + expect(getByText('is-team-non-draft-is-archive')).toBeInTheDocument(); + expect(getAllByText('non-team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(1); + expect(queryAllByTitle('Draft PR')).toHaveLength(0); + }); + }); + + describe('draft PRs', () => { + it('should show draft team PRs for un-archived repos when archived option is not checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const teamToggle = getByTitle('Show PRs from your team'); + fireEvent.click(teamToggle); + const draftToggle = getByTitle('Show draft PRs'); + fireEvent.click(draftToggle); + expect(getByText('is-team-is-draft-non-archive')).toBeInTheDocument(); + expect(getAllByText('non-team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(0); + expect(queryAllByTitle('Draft PR')).toHaveLength(1); + }); + + it('should show draft team PRs for archived repos when archived option is checked', async () => { + const { getByText, getAllByText, getByTitle, queryAllByTitle } = render( + , + ); + const teamToggle = getByTitle('Show PRs from your team'); + fireEvent.click(teamToggle); + const draftToggle = getByTitle('Show draft PRs'); + fireEvent.click(draftToggle); + const archiveToggle = getByTitle('Show archived repos'); + fireEvent.click(archiveToggle); + expect(getByText('is-team-is-draft-is-archive')).toBeInTheDocument(); + expect(getAllByText('non-team-repo')).toHaveLength(1); + expect(queryAllByTitle('Repository is archived')).toHaveLength(1); + expect(queryAllByTitle('Draft PR')).toHaveLength(1); + }); + }); + }); +}); diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.tsx new file mode 100644 index 000000000..2cc6398f9 --- /dev/null +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/PullRequestsBoard.tsx @@ -0,0 +1,163 @@ +/* + * Copyright 2022 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 { Grid, Typography } from '@material-ui/core'; +import PeopleIcon from '@material-ui/icons/People'; +import { Progress, InfoCard } from '@backstage/core-components'; + +import { InfoCardHeader } from '../InfoCardHeader'; +import { PullRequestBoardOptions } from '../PullRequestBoardOptions'; +import { Wrapper } from '../Wrapper'; +import { PullRequestCard } from '../PullRequestCard'; +import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam'; +import { PRCardFormating } from '../../utils/types'; +import { shouldDisplayCard } from '../../utils/functions'; +import { DraftPrIcon } from '../icons/DraftPr'; +import UnarchiveIcon from '@material-ui/icons/Unarchive'; +import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam'; +import { Entity } from '@backstage/catalog-model'; + +/** @public */ +export interface PullRequestsBoardProps { + /** + * List of entities to display pull requests for. + * If not provided, the board will use the current user's repositories and team. + */ + entities: Entity[]; + + pullRequestLimit?: number; +} + +/** + * A board component that displays pull requests for multiple entities. + * It aggregates pull requests from the provided entities and allows filtering by team, draft status, and archived repositories. + * @public + * */ +const PullRequestsBoard = (props: PullRequestsBoardProps) => { + const { entities, pullRequestLimit } = props; + + const { + loading: loadingReposAndTeams, + repositories, + teamMembers, + teamMembersOrganization, + } = useUserRepositoriesAndTeam(entities); + const [infoCardFormat, setInfoCardFormat] = useState([]); + + const { + loading: loadingPRs, + pullRequests, + refreshPullRequests, + } = usePullRequestsByTeam( + repositories, + teamMembers, + teamMembersOrganization, + pullRequestLimit, + ); + + const header = ( + + setInfoCardFormat(newFormats)} + value={infoCardFormat} + options={[ + { + icon: , + value: 'team', + ariaLabel: 'Show PRs from your team', + }, + { + icon: , + value: 'draft', + ariaLabel: 'Show draft PRs', + }, + { + icon: , + value: 'archivedRepo', + ariaLabel: 'Show archived repos', + }, + ]} + /> + + ); + + const getContent = () => { + if (loadingReposAndTeams || loadingPRs) { + return ; + } + + return ( + + {pullRequests.length ? ( + pullRequests.map(({ title: columnTitle, content }) => ( + + {columnTitle} + {content.map( + ( + { + id, + title, + createdAt, + lastEditedAt, + author, + url, + latestReviews, + commits, + repository, + isDraft, + labels, + }, + index, + ) => + shouldDisplayCard( + repository, + author, + repositories, + teamMembers, + infoCardFormat, + isDraft, + ) && ( + + ), + )} + + )) + ) : ( + + No pull requests found + + )} + + ); + }; + + return {getContent()}; +}; + +export default PullRequestsBoard; diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/index.ts b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/index.ts new file mode 100644 index 000000000..ff976eb56 --- /dev/null +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/components/PullRequestsBoard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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 { default } from './PullRequestsBoard'; +export type { PullRequestsBoardProps } from './PullRequestsBoard'; diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.test.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.test.tsx new file mode 100644 index 000000000..4d003b895 --- /dev/null +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.test.tsx @@ -0,0 +1,210 @@ +/* + * Copyright 2025 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 { renderHook, waitFor } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { Entity } from '@backstage/catalog-model'; +import { useUserRepositoriesAndTeam } from './useUserRepositoriesAndTeam'; +import React from 'react'; + +const mockTeamEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-one', + namespace: 'default', + annotations: { + 'github.com/team-slug': 'test-org/team-one', + }, + }, + spec: { + type: 'team', + }, +}; + +const mockTeamTwoEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-two', + namespace: 'default', + annotations: { + 'github.com/team-slug': 'test-org/team-two', + }, + }, + spec: { + type: 'team', + }, +}; + +const mockComponentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'repo-one', + namespace: 'default', + annotations: { + 'github.com/project-slug': 'test-org/repo-one', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'group:default/team-one', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/team-one', + }, + ], +}; + +const mockUserEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user-one', + namespace: 'default', + annotations: { + 'github.com/user-login': 'user-one-github', + }, + }, + spec: { + profile: { + displayName: 'User One', + }, + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-one', + }, + ], +}; + +const mockRepoTwoEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'repo-two', + namespace: 'default', + annotations: { + 'github.com/project-slug': 'test-org/repo-two', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'group:default/team-two', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/team-two', + }, + ], +}; + +const mockUserTwoEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user-two', + namespace: 'default', + annotations: { + 'github.com/user-login': 'user-two-github', + }, + }, + spec: { + profile: { + displayName: 'User Two', + }, + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-two', + }, + ], +}; + +const catalogApi = catalogApiMock({ + entities: [ + mockTeamEntity, + mockTeamTwoEntity, + mockComponentEntity, + mockRepoTwoEntity, + mockUserEntity, + mockUserTwoEntity, + ], +}); + +describe('useUserRepositoriesAndTeam', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should return repositories and team members for a team entity', async () => { + const { result } = renderHook( + () => useUserRepositoriesAndTeam(mockTeamEntity), + { wrapper }, + ); + + // Initially should be loading + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.repositories).toEqual(['test-org/repo-one']); + expect(result.current.teamMembers).toEqual(['user-one-github']); + expect(result.current.teamMembersOrganization).toBe('test-org'); + }); + + it('should return repositories and team members for multiple team entities', async () => { + const { result } = renderHook( + () => useUserRepositoriesAndTeam([mockTeamEntity, mockTeamTwoEntity]), + { wrapper }, + ); + + // Initially should be loading + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Should aggregate repositories and team members from both teams + expect(result.current.repositories).toEqual([ + 'test-org/repo-one', + 'test-org/repo-two', + ]); + expect(result.current.teamMembers).toEqual([ + 'user-one-github', + 'user-two-github', + ]); + expect(result.current.teamMembersOrganization).toBe('test-org'); + }); +}); diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.tsx b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.tsx index 162dadcfb..d60e73649 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.tsx +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/hooks/useUserRepositoriesAndTeam.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; -import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useCallback, useEffect, useState } from 'react'; import { getProjectNameFromEntity, @@ -24,8 +24,11 @@ import { getUserNameFromEntity, } from '../utils/functions'; -export function useUserRepositoriesAndTeam() { - const { entity: teamEntity } = useEntity(); +export function useUserRepositoriesAndTeam(teamEntities: Entity | Entity[]) { + const ownerEntities = Array.isArray(teamEntities) + ? teamEntities + : [teamEntities]; + const catalogApi = useApi(catalogApiRef); const [loading, setLoading] = useState(true); const [teamData, setTeamData] = useState<{ @@ -36,14 +39,18 @@ export function useUserRepositoriesAndTeam() { teamMembers: [], }); + const entitiesRefs = ownerEntities.map(e => stringifyEntityRef(e)); + const getTeamData = useCallback(async () => { setLoading(true); // get team repositories and members const entitiesList = await catalogApi.getEntities({ filter: [ - { 'relations.ownedBy': stringifyEntityRef(teamEntity) }, - { 'relations.memberOf': stringifyEntityRef(teamEntity) }, + { 'relations.ownedBy': entitiesRefs }, + { + 'relations.memberOf': entitiesRefs, + }, ], }); @@ -66,7 +73,8 @@ export function useUserRepositoriesAndTeam() { teamMembers: [...new Set(teamMembersNames)], }); setLoading(false); - }, [catalogApi, teamEntity]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catalogApi, entitiesRefs.join(',')]); useEffect(() => { getTeamData(); @@ -76,6 +84,6 @@ export function useUserRepositoriesAndTeam() { loading, repositories: teamData.repositories, teamMembers: teamData.teamMembers, - teamMembersOrganization: getGithubOrganizationFromEntity(teamEntity), + teamMembersOrganization: getGithubOrganizationFromEntity(ownerEntities[0]), }; } diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/index.ts b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/index.ts index c93c1732b..6eb5b4cc0 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/index.ts +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/index.ts @@ -17,5 +17,7 @@ export { EntityTeamPullRequestsCard, EntityTeamPullRequestsContent, } from './plugin'; +export { default as PullRequestsBoard } from './components/PullRequestsBoard'; export type { EntityTeamPullRequestsCardProps } from './components/EntityTeamPullRequestsCard'; export type { EntityTeamPullRequestsContentProps } from './components/EntityTeamPullRequestsContent'; +export type { PullRequestsBoardProps } from './components/PullRequestsBoard'; diff --git a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/plugin.ts b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/plugin.ts index 87aea76d3..41b4a2243 100644 --- a/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/plugin.ts +++ b/workspaces/github-pull-requests-board/plugins/github-pull-requests-board/src/plugin.ts @@ -52,3 +52,6 @@ export const EntityTeamPullRequestsContent = mountPoint: rootRouteRef, }), ); + +/** @public */ +export default githubPullRequestsBoardPlugin; diff --git a/workspaces/github-pull-requests-board/yarn.lock b/workspaces/github-pull-requests-board/yarn.lock index 26814bd31..9a59857ec 100644 --- a/workspaces/github-pull-requests-board/yarn.lock +++ b/workspaces/github-pull-requests-board/yarn.lock @@ -1738,6 +1738,7 @@ __metadata: "@backstage/core-compat-api": "npm:^0.4.4" "@backstage/core-components": "npm:^0.17.4" "@backstage/core-plugin-api": "npm:^1.10.9" + "@backstage/dev-utils": "npm:^1.1.12" "@backstage/frontend-plugin-api": "npm:^0.10.4" "@backstage/frontend-test-utils": "npm:^0.3.4" "@backstage/integration": "npm:^1.17.1" @@ -1764,6 +1765,29 @@ __metadata: languageName: unknown linkType: soft +"@backstage/app-defaults@npm:^1.6.4": + version: 1.6.4 + resolution: "@backstage/app-defaults@npm:1.6.4" + dependencies: + "@backstage/core-app-api": "npm:^1.18.0" + "@backstage/core-components": "npm:^0.17.4" + "@backstage/core-plugin-api": "npm:^1.10.9" + "@backstage/plugin-permission-react": "npm:^0.4.36" + "@backstage/theme": "npm:^0.6.7" + "@material-ui/core": "npm:^4.12.2" + "@material-ui/icons": "npm:^4.9.1" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/fb88e41cde626ac91b277ade2f2b2e881cffc61d76497569b483f84231947c2afce7259d2f79e43fadfb02c40254c4f3426b3890a29b26ab522a9c7e2d0135a1 + languageName: node + linkType: hard + "@backstage/backend-plugin-api@npm:^1.4.1": version: 1.4.1 resolution: "@backstage/backend-plugin-api@npm:1.4.1" @@ -2126,6 +2150,33 @@ __metadata: languageName: node linkType: hard +"@backstage/dev-utils@npm:^1.1.12": + version: 1.1.12 + resolution: "@backstage/dev-utils@npm:1.1.12" + dependencies: + "@backstage/app-defaults": "npm:^1.6.4" + "@backstage/catalog-model": "npm:^1.7.5" + "@backstage/core-app-api": "npm:^1.18.0" + "@backstage/core-components": "npm:^0.17.4" + "@backstage/core-plugin-api": "npm:^1.10.9" + "@backstage/integration-react": "npm:^1.2.9" + "@backstage/plugin-catalog-react": "npm:^1.19.1" + "@backstage/theme": "npm:^0.6.7" + "@material-ui/core": "npm:^4.12.2" + "@material-ui/icons": "npm:^4.9.1" + react-use: "npm:^17.2.4" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/1a00f6a7b7ccdcbb3fd1c9c144687fd29e04698d4f846b63d4294c8e5136c8ed640f5fb3da88109cbcce9eb73355b07d0a59b15a8486fe55c1e61634fb60fd3f + languageName: node + linkType: hard + "@backstage/e2e-test-utils@npm:^0.1.1": version: 0.1.1 resolution: "@backstage/e2e-test-utils@npm:0.1.1"