github-pull-request-board decouple board from entity page (#4710)

* Decouple entities from the board logic for reuse the board on other places

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>

* mock useEntity

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>

* revert the change
Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>

---------

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
Juan Pablo Garcia Ripa 2025-08-22 22:16:31 +02:00 committed by GitHub
parent 56382c80de
commit c2b33a16aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1072 additions and 130 deletions

View File

@ -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

View File

@ -0,0 +1,6 @@
app:
title: Example App
baseUrl: http://localhost:3000
backend:
baseUrl: http://localhost:7007

View File

@ -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",

View File

@ -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: (
<TestApiProvider
apis={[
[catalogApiRef, catalogApiMockImp],
[githubAuthApiRef, githubMockApi],
]}
>
<EntityProvider entity={teamEntity}>
<Page themeId="service">
<Header title="Mocked Pull Requests Board">
<HeaderLabel label="Mode" value="Development" />
</Header>
<Content>
<EntityTeamPullRequestsContent />
</Content>
</Page>
</EntityProvider>
</TestApiProvider>
),
title: 'Entity Todo Content',
icon: OfflineIcon,
})
.addPage({
element: (
<TestApiProvider
apis={[
[catalogApiRef, catalogApiMockImp],
[githubAuthApiRef, githubMockApi],
]}
>
<EntityProvider entity={teamEntity}>
<Page themeId="service">
<Header title="Mocked Pull Requests Board">
<HeaderLabel label="Mode" value="Development" />
</Header>
<Content>
<EntityTeamPullRequestsContent />
</Content>
</Page>
</EntityProvider>
</TestApiProvider>
),
title: 'Live Pull Requests Board',
icon: Wifi,
})
.render();

View File

@ -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",

View File

@ -5,7 +5,9 @@
```ts
/// <reference types="react" />
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)
```

View File

@ -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,

View File

@ -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<PRCardFormating[]>([]);
const { entity: teamEntity } = useEntity();
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
} = useUserRepositoriesAndTeam(teamEntity);
const {
loading: loadingPRs,
pullRequests,

View File

@ -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,

View File

@ -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<PRCardFormating[]>([]);
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
const { entity: teamEntity } = useEntity();
return (
<PullRequestsBoard
entities={[teamEntity]}
pullRequestLimit={pullRequestLimit}
/>
);
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
ariaLabel: 'Show draft PRs',
},
{
icon: <UnarchiveIcon />,
value: 'archivedRepo',
ariaLabel: 'Show archived repos',
},
]}
/>
</InfoCardHeader>
);
const getContent = () => {
if (loadingReposAndTeam || loadingPRs) {
return <Progress />;
}
return (
<Grid container spacing={2}>
{pullRequests.length ? (
pullRequests.map(({ title: columnTitle, content }) => (
<Wrapper key={columnTitle} fullscreen>
<Typography variant="overline">{columnTitle}</Typography>
{content.map(
(
{
id,
title,
createdAt,
lastEditedAt,
author,
url,
latestReviews,
commits,
repository,
isDraft,
labels,
},
index,
) =>
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
status={commits.nodes}
repositoryName={repository.name}
repositoryIsArchived={repository.isArchived}
isDraft={isDraft}
labels={labels.nodes}
/>
),
)}
</Wrapper>
))
) : (
<Typography variant="overline" data-testid="no-prs-msg">
No pull requests found
</Typography>
)}
</Grid>
);
};
return <InfoCard title={header}>{getContent()}</InfoCard>;
};
export default EntityTeamPullRequestsContent;

View File

@ -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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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(
<PullRequestsBoard entities={mockEntities} />,
);
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);
});
});
});
});

View File

@ -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<PRCardFormating[]>([]);
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
);
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
ariaLabel: 'Show draft PRs',
},
{
icon: <UnarchiveIcon />,
value: 'archivedRepo',
ariaLabel: 'Show archived repos',
},
]}
/>
</InfoCardHeader>
);
const getContent = () => {
if (loadingReposAndTeams || loadingPRs) {
return <Progress />;
}
return (
<Grid container spacing={2}>
{pullRequests.length ? (
pullRequests.map(({ title: columnTitle, content }) => (
<Wrapper key={columnTitle} fullscreen>
<Typography variant="overline">{columnTitle}</Typography>
{content.map(
(
{
id,
title,
createdAt,
lastEditedAt,
author,
url,
latestReviews,
commits,
repository,
isDraft,
labels,
},
index,
) =>
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
status={commits.nodes}
repositoryName={repository.name}
repositoryIsArchived={repository.isArchived}
isDraft={isDraft}
labels={labels.nodes}
/>
),
)}
</Wrapper>
))
) : (
<Typography variant="overline" data-testid="no-prs-msg">
No pull requests found
</Typography>
)}
</Grid>
);
};
return <InfoCard title={header}>{getContent()}</InfoCard>;
};
export default PullRequestsBoard;

View File

@ -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';

View File

@ -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 }) => (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
{children}
</TestApiProvider>
);
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');
});
});

View File

@ -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<boolean>(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]),
};
}

View File

@ -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';

View File

@ -52,3 +52,6 @@ export const EntityTeamPullRequestsContent =
mountPoint: rootRouteRef,
}),
);
/** @public */
export default githubPullRequestsBoardPlugin;

View File

@ -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"