Merge pull request #14022 from dagda1/filter-github-issues-by-label
Filter GitHub issues by label
This commit is contained in:
@@ -61,3 +61,20 @@ However, you can configure the plugin with props:
|
||||
|
||||
- `itemsPerPage: number = 10` - Issues in the list are paginated, number of issues on a single page is controlled with this prop
|
||||
- `itemsPerRepo: number = 40` - the plugin doesn't download all Issues available on GitHub. By default, it will get at most 40 Issues - this prop controls this behaviour
|
||||
- `filterBy: object` - the plugin can be configured to filter the query by `assignee`, `createdBy`, `labels`, `states`, `mentioned` or `milestone`.
|
||||
- `orderBy: object = { field: 'UPDATED_AT', direction: 'DESC' }` - The ordering that the issues are returned can be configured by the `orderBy` field.
|
||||
|
||||
### `filterBy` and `orderBy` example
|
||||
|
||||
```ts
|
||||
<GitHubIssuesCard
|
||||
filterBy={{
|
||||
labels: ['bug', 'enhancement'],
|
||||
states: ['OPEN'],
|
||||
}}
|
||||
orderBy={{
|
||||
field: 'COMMENTS',
|
||||
direction: 'ASC',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -11,6 +11,30 @@ import { RouteRef } from '@backstage/core-plugin-api';
|
||||
// @public (undocumented)
|
||||
export const GitHubIssuesCard: (props: GitHubIssuesProps) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface GithubIssuesFilters {
|
||||
// (undocumented)
|
||||
assignee?: string;
|
||||
// (undocumented)
|
||||
createdBy?: string;
|
||||
// (undocumented)
|
||||
labels?: string[];
|
||||
// (undocumented)
|
||||
mentioned?: string;
|
||||
// (undocumented)
|
||||
milestone?: string;
|
||||
// (undocumented)
|
||||
states?: ('OPEN' | 'CLOSED')[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface GithubIssuesOrdering {
|
||||
// (undocumented)
|
||||
direction?: 'ASC' | 'DESC';
|
||||
// (undocumented)
|
||||
field: 'CREATED_AT' | 'UPDATED_AT' | 'COMMENTS';
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const GitHubIssuesPage: (props: GitHubIssuesProps) => JSX.Element;
|
||||
|
||||
@@ -27,7 +51,17 @@ export const gitHubIssuesPlugin: BackstagePlugin<
|
||||
export type GitHubIssuesProps = {
|
||||
itemsPerPage?: number;
|
||||
itemsPerRepo?: number;
|
||||
filterBy?: GithubIssuesFilters;
|
||||
orderBy?: GithubIssuesOrdering;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface GitubIssuesByRepoOptions {
|
||||
// (undocumented)
|
||||
filterBy?: GithubIssuesFilters;
|
||||
// (undocumented)
|
||||
orderBy?: GithubIssuesOrdering;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -20,84 +20,152 @@ jest.mock('octokit', () => ({
|
||||
|
||||
import { ConfigApi, ErrorApi } from '@backstage/core-plugin-api';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { gitHubIssuesApi } from './gitHubIssuesApi';
|
||||
import { createFilterByClause, gitHubIssuesApi } from './gitHubIssuesApi';
|
||||
import type { GithubIssuesFilters } from './gitHubIssuesApi';
|
||||
|
||||
function getFragment(
|
||||
filterBy = '',
|
||||
orderBy = 'field: UPDATED_AT, direction: DESC',
|
||||
) {
|
||||
return (
|
||||
'\n' +
|
||||
' \n' +
|
||||
' fragment issues on Repository {\n' +
|
||||
' issues(\n' +
|
||||
' states: OPEN\n' +
|
||||
' first: 10\n' +
|
||||
` filterBy: { ${filterBy} }\n` +
|
||||
` orderBy: { ${orderBy} }\n` +
|
||||
' ) {\n' +
|
||||
' totalCount\n' +
|
||||
' edges {\n' +
|
||||
' node {\n' +
|
||||
' assignees(first: 10) {\n' +
|
||||
' edges {\n' +
|
||||
' node {\n' +
|
||||
' avatarUrl\n' +
|
||||
' login\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' author {\n' +
|
||||
' login\n' +
|
||||
' }\n' +
|
||||
' repository {\n' +
|
||||
' nameWithOwner\n' +
|
||||
' }\n' +
|
||||
' title\n' +
|
||||
' url\n' +
|
||||
' participants {\n' +
|
||||
' totalCount\n' +
|
||||
' }\n' +
|
||||
' updatedAt\n' +
|
||||
' createdAt\n' +
|
||||
' comments(last: 1) {\n' +
|
||||
' totalCount\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' \n' +
|
||||
'\n' +
|
||||
' query {\n' +
|
||||
' \n' +
|
||||
' yoyo: repository(name: "yo-yo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' ,\n' +
|
||||
' yoyox: repository(name: "yoyo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' ,\n' +
|
||||
' yoyoxx: repository(name: "yo.yo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' \n' +
|
||||
' } \n' +
|
||||
' '
|
||||
);
|
||||
}
|
||||
|
||||
describe('gitHubIssuesApi', () => {
|
||||
describe('fetchIssuesByRepoFromGitHub', () => {
|
||||
it('should call GitHub API with correct query with fragment for each repo', async () => {
|
||||
const api = gitHubIssuesApi(
|
||||
let api: ReturnType<typeof gitHubIssuesApi>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
api = gitHubIssuesApi(
|
||||
{ getAccessToken: jest.fn() },
|
||||
{
|
||||
getOptionalConfigArray: jest.fn(),
|
||||
} as unknown as ConfigApi,
|
||||
{ post: jest.fn() } as unknown as ErrorApi,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call GitHub API with correct query with fragment for each repo', async () => {
|
||||
await api.fetchIssuesByRepoFromGitHub(
|
||||
['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
|
||||
10,
|
||||
);
|
||||
|
||||
expect(mockGraphQLQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockGraphQLQuery).toHaveBeenCalledWith(getFragment());
|
||||
});
|
||||
|
||||
it('should call Github API with the correct filterBy and orderBy clauses', async () => {
|
||||
await api.fetchIssuesByRepoFromGitHub(
|
||||
['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
|
||||
10,
|
||||
{
|
||||
filterBy: {
|
||||
labels: ['bug'],
|
||||
states: ['OPEN'],
|
||||
assignee: 'someone',
|
||||
},
|
||||
orderBy: {
|
||||
field: 'COMMENTS',
|
||||
direction: 'ASC',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const expectedFilterBy = `labels: [ "bug"], states: OPEN, assignee: "someone"`;
|
||||
const expectedOrderBy = 'field: COMMENTS, direction: ASC';
|
||||
|
||||
expect(mockGraphQLQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockGraphQLQuery).toHaveBeenCalledWith(
|
||||
'\n' +
|
||||
' \n' +
|
||||
' fragment issues on Repository {\n' +
|
||||
' issues(\n' +
|
||||
' states: OPEN\n' +
|
||||
' first: 10\n' +
|
||||
' orderBy: { field: UPDATED_AT, direction: DESC }\n' +
|
||||
' ) {\n' +
|
||||
' totalCount\n' +
|
||||
' edges {\n' +
|
||||
' node {\n' +
|
||||
' assignees(first: 10) {\n' +
|
||||
' edges {\n' +
|
||||
' node {\n' +
|
||||
' avatarUrl\n' +
|
||||
' login\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' author {\n' +
|
||||
' login\n' +
|
||||
' }\n' +
|
||||
' repository {\n' +
|
||||
' nameWithOwner\n' +
|
||||
' }\n' +
|
||||
' title\n' +
|
||||
' url\n' +
|
||||
' participants {\n' +
|
||||
' totalCount\n' +
|
||||
' }\n' +
|
||||
' updatedAt\n' +
|
||||
' createdAt\n' +
|
||||
' comments(last: 1) {\n' +
|
||||
' totalCount\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
' \n' +
|
||||
'\n' +
|
||||
' query {\n' +
|
||||
' \n' +
|
||||
' yoyo: repository(name: "yo-yo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' ,\n' +
|
||||
' yoyox: repository(name: "yoyo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' ,\n' +
|
||||
' yoyoxx: repository(name: "yo.yo", owner: "mrwolny") {\n' +
|
||||
' ...issues\n' +
|
||||
' }\n' +
|
||||
' \n' +
|
||||
' } \n' +
|
||||
' ',
|
||||
getFragment(expectedFilterBy, expectedOrderBy),
|
||||
);
|
||||
});
|
||||
|
||||
describe('filterBy', () => {
|
||||
const cases: [GithubIssuesFilters | undefined, string][] = [
|
||||
[{}, ''],
|
||||
[undefined, ''],
|
||||
[{ states: ['OPEN'] }, 'states: OPEN'],
|
||||
[
|
||||
{ labels: ['bug', 'enhancement'], assignee: 'someone' },
|
||||
`labels: [ \"bug\", \"enhancement\"], assignee: "someone"`,
|
||||
],
|
||||
[
|
||||
{
|
||||
createdBy: 'someone',
|
||||
mentioned: 'someone else',
|
||||
milestone: 'milestone',
|
||||
},
|
||||
`createdBy: \"someone\", mentioned: \"someone else\", milestone: \"milestone\"`,
|
||||
],
|
||||
];
|
||||
|
||||
test.each(cases)('filterBy(%s) should be %s', (filterBy, expected) => {
|
||||
expect(createFilterByClause(filterBy)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return data for repos with successfully retrieved issues when GitHub returns partial failure', async () => {
|
||||
|
||||
@@ -73,6 +73,34 @@ export type IssuesByRepo = Record<string, RepoIssues>;
|
||||
/** @internal */
|
||||
export type GitHubIssuesApi = ReturnType<typeof gitHubIssuesApi>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubIssuesFilters {
|
||||
assignee?: string;
|
||||
createdBy?: string;
|
||||
labels?: string[];
|
||||
mentioned?: string;
|
||||
milestone?: string;
|
||||
states?: ('OPEN' | 'CLOSED')[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubIssuesOrdering {
|
||||
field: 'CREATED_AT' | 'UPDATED_AT' | 'COMMENTS';
|
||||
direction?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GitubIssuesByRepoOptions {
|
||||
filterBy?: GithubIssuesFilters;
|
||||
orderBy?: GithubIssuesOrdering;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const gitHubIssuesApiRef = createApiRef<GitHubIssuesApi>({
|
||||
id: 'plugin.githubissues.service',
|
||||
@@ -103,6 +131,13 @@ export const gitHubIssuesApi = (
|
||||
const fetchIssuesByRepoFromGitHub = async (
|
||||
repos: Array<string>,
|
||||
itemsPerRepo: number,
|
||||
{
|
||||
filterBy,
|
||||
orderBy = {
|
||||
field: 'UPDATED_AT',
|
||||
direction: 'DESC',
|
||||
},
|
||||
}: GitubIssuesByRepoOptions = {},
|
||||
): Promise<IssuesByRepo> => {
|
||||
const graphql = await getOctokit();
|
||||
const safeNames: Array<string> = [];
|
||||
@@ -129,7 +164,10 @@ export const gitHubIssuesApi = (
|
||||
let issuesByRepo: IssuesByRepo = {};
|
||||
try {
|
||||
issuesByRepo = await graphql(
|
||||
createIssueByRepoQuery(repositories, itemsPerRepo),
|
||||
createIssueByRepoQuery(repositories, itemsPerRepo, {
|
||||
filterBy,
|
||||
orderBy,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.data) {
|
||||
@@ -151,6 +189,34 @@ export const gitHubIssuesApi = (
|
||||
return { fetchIssuesByRepoFromGitHub };
|
||||
};
|
||||
|
||||
function formatFilterValue(
|
||||
value: GithubIssuesFilters[keyof GithubIssuesFilters],
|
||||
): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[ ${value.map(formatFilterValue).join(', ')}]`;
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? `\"${value}\"` : `${value}`;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createFilterByClause(filterBy?: GithubIssuesFilters): string {
|
||||
if (!filterBy) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Object.entries(filterBy)
|
||||
.filter(value => value)
|
||||
.map(([field, value]) => {
|
||||
if (field === 'states') {
|
||||
return `${field}: ${value.join(', ')}`;
|
||||
}
|
||||
|
||||
return `${field}: ${formatFilterValue(value)}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function createIssueByRepoQuery(
|
||||
repositories: Array<{
|
||||
safeName: string;
|
||||
@@ -158,13 +224,15 @@ function createIssueByRepoQuery(
|
||||
owner: string;
|
||||
}>,
|
||||
itemsPerRepo: number,
|
||||
{ filterBy, orderBy }: GitubIssuesByRepoOptions,
|
||||
): string {
|
||||
const fragment = `
|
||||
fragment issues on Repository {
|
||||
issues(
|
||||
states: OPEN
|
||||
first: ${itemsPerRepo}
|
||||
orderBy: { field: UPDATED_AT, direction: DESC }
|
||||
filterBy: { ${createFilterByClause(filterBy)} }
|
||||
orderBy: { field: ${orderBy?.field}, direction: ${orderBy?.direction} }
|
||||
) {
|
||||
totalCount
|
||||
edges {
|
||||
|
||||
@@ -24,6 +24,10 @@ import { useGetIssuesByRepoFromGitHub } from '../../hooks/useGetIssuesByRepoFrom
|
||||
|
||||
import { IssuesList } from './IssuesList';
|
||||
import { NoRepositoriesInfo } from './NoRepositoriesInfo';
|
||||
import type {
|
||||
GithubIssuesFilters,
|
||||
GithubIssuesOrdering,
|
||||
} from '../../api/gitHubIssuesApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -31,17 +35,22 @@ import { NoRepositoriesInfo } from './NoRepositoriesInfo';
|
||||
export type GitHubIssuesProps = {
|
||||
itemsPerPage?: number;
|
||||
itemsPerRepo?: number;
|
||||
filterBy?: GithubIssuesFilters;
|
||||
orderBy?: GithubIssuesOrdering;
|
||||
};
|
||||
|
||||
export const GitHubIssues = (props: GitHubIssuesProps) => {
|
||||
const { itemsPerPage = 10, itemsPerRepo = 40 } = props;
|
||||
const { itemsPerPage = 10, itemsPerRepo = 40, filterBy, orderBy } = props;
|
||||
|
||||
const { repositories } = useEntityGitHubRepositories();
|
||||
const {
|
||||
isLoading,
|
||||
gitHubIssuesByRepo: issuesByRepository,
|
||||
retry,
|
||||
} = useGetIssuesByRepoFromGitHub(repositories, itemsPerRepo);
|
||||
} = useGetIssuesByRepoFromGitHub(repositories, itemsPerRepo, {
|
||||
filterBy,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!repositories.length) {
|
||||
return <NoRepositoriesInfo />;
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
|
||||
import { gitHubIssuesApiRef } from '../api';
|
||||
import { gitHubIssuesApiRef, GitubIssuesByRepoOptions } from '../api';
|
||||
|
||||
export const useGetIssuesByRepoFromGitHub = (
|
||||
repos: Array<string>,
|
||||
itemsPerRepo: number,
|
||||
options?: GitubIssuesByRepoOptions,
|
||||
) => {
|
||||
const gitHubIssuesApi = useApi(gitHubIssuesApiRef);
|
||||
|
||||
@@ -33,6 +34,7 @@ export const useGetIssuesByRepoFromGitHub = (
|
||||
return await gitHubIssuesApi.fetchIssuesByRepoFromGitHub(
|
||||
repos,
|
||||
itemsPerRepo,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +20,8 @@ export {
|
||||
} from './plugin';
|
||||
|
||||
export type { GitHubIssuesProps } from './components/GitHubIssues';
|
||||
export type {
|
||||
GithubIssuesFilters,
|
||||
GithubIssuesOrdering,
|
||||
GitubIssuesByRepoOptions,
|
||||
} from './api/gitHubIssuesApi';
|
||||
|
||||
Reference in New Issue
Block a user