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