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"