Merge pull request #14022 from dagda1/filter-github-issues-by-label

Filter GitHub issues by label
This commit is contained in:
Johan Haals
2022-10-07 16:13:58 +02:00
committed by GitHub
8 changed files with 273 additions and 65 deletions
+17
View File
@@ -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',
}}
/>
```
+34
View File
@@ -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,
);
}
+5
View File
@@ -20,3 +20,8 @@ export {
} from './plugin';
export type { GitHubIssuesProps } from './components/GitHubIssues';
export type {
GithubIssuesFilters,
GithubIssuesOrdering,
GitubIssuesByRepoOptions,
} from './api/gitHubIssuesApi';