diff --git a/.changeset/young-olives-cheer.md b/.changeset/young-olives-cheer.md new file mode 100644 index 0000000000..536a8d6370 --- /dev/null +++ b/.changeset/young-olives-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-issues': patch +--- + +Add filtering and ordering to the graphql query diff --git a/plugins/github-issues/README.md b/plugins/github-issues/README.md index eeb93ab27b..d35f2c5d3f 100644 --- a/plugins/github-issues/README.md +++ b/plugins/github-issues/README.md @@ -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 + +``` diff --git a/plugins/github-issues/api-report.md b/plugins/github-issues/api-report.md index eb69fc8e8e..41b4f87064 100644 --- a/plugins/github-issues/api-report.md +++ b/plugins/github-issues/api-report.md @@ -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) ``` diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.test.ts b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts index 4d1c3ffff3..b2be54a2d1 100644 --- a/plugins/github-issues/src/api/gitHubIssuesApi.test.ts +++ b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts @@ -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; + + 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 () => { diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.ts b/plugins/github-issues/src/api/gitHubIssuesApi.ts index 816345940c..b4be445d13 100644 --- a/plugins/github-issues/src/api/gitHubIssuesApi.ts +++ b/plugins/github-issues/src/api/gitHubIssuesApi.ts @@ -73,6 +73,34 @@ export type IssuesByRepo = Record; /** @internal */ export type GitHubIssuesApi = ReturnType; +/** + * @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({ id: 'plugin.githubissues.service', @@ -103,6 +131,13 @@ export const gitHubIssuesApi = ( const fetchIssuesByRepoFromGitHub = async ( repos: Array, itemsPerRepo: number, + { + filterBy, + orderBy = { + field: 'UPDATED_AT', + direction: 'DESC', + }, + }: GitubIssuesByRepoOptions = {}, ): Promise => { const graphql = await getOctokit(); const safeNames: Array = []; @@ -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 { diff --git a/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx index 574c15d3b3..cb6639c83c 100644 --- a/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx +++ b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx @@ -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 ; diff --git a/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts b/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts index 8d1d0c2056..693be9e4d9 100644 --- a/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts +++ b/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts @@ -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, itemsPerRepo: number, + options?: GitubIssuesByRepoOptions, ) => { const gitHubIssuesApi = useApi(gitHubIssuesApiRef); @@ -33,6 +34,7 @@ export const useGetIssuesByRepoFromGitHub = ( return await gitHubIssuesApi.fetchIssuesByRepoFromGitHub( repos, itemsPerRepo, + options, ); } diff --git a/plugins/github-issues/src/index.ts b/plugins/github-issues/src/index.ts index 5f6c248db7..7717ba5f33 100644 --- a/plugins/github-issues/src/index.ts +++ b/plugins/github-issues/src/index.ts @@ -20,3 +20,8 @@ export { } from './plugin'; export type { GitHubIssuesProps } from './components/GitHubIssues'; +export type { + GithubIssuesFilters, + GithubIssuesOrdering, + GitubIssuesByRepoOptions, +} from './api/gitHubIssuesApi';