diff --git a/.changeset/gold-dots-search.md b/.changeset/gold-dots-search.md
new file mode 100644
index 0000000000..e2a679dff7
--- /dev/null
+++ b/.changeset/gold-dots-search.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-github-issues': patch
+---
+
+Moved communication with GitHub graphql API to the dedicated plugin API.
+Fixes issue when no GitHub Issues are rendered when partial failure is returned from GitHub API.
diff --git a/plugins/github-issues/dev/__fixtures__/component-issues-data.json b/plugins/github-issues/dev/__fixtures__/component-issues-data.json
new file mode 100644
index 0000000000..8192f38838
--- /dev/null
+++ b/plugins/github-issues/dev/__fixtures__/component-issues-data.json
@@ -0,0 +1,1209 @@
+{
+ "backstage": {
+ "issues": {
+ "totalCount": 464,
+ "edges": [
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1112.jpg",
+ "login": "worthless-horse"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "next-dog"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "quasi labore qui",
+ "url": "http://flowery-muscatel.net",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2022-05-02T09:46:35.885Z",
+ "createdAt": "2022-06-03T07:11:22.320Z",
+ "comments": {
+ "totalCount": 6
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1061.jpg",
+ "login": "colorful-bird"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "loud-cow"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "debitis alias voluptatem",
+ "url": "https://complete-grief.biz",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-05-04T19:44:01.090Z",
+ "createdAt": "2022-02-21T08:07:57.434Z",
+ "comments": {
+ "totalCount": 1
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1222.jpg",
+ "login": "sharp-cetacean"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "growing-dog"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "non accusamus occaecati",
+ "url": "http://medium-choosing.com",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2021-10-28T00:36:58.871Z",
+ "createdAt": "2021-11-08T02:21:55.269Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/513.jpg",
+ "login": "crafty-crocodilia"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "wooden-crocodilia"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "tenetur distinctio sint",
+ "url": "http://opulent-prejudice.biz",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-08-10T19:22:08.823Z",
+ "createdAt": "2022-02-21T06:24:45.090Z",
+ "comments": {
+ "totalCount": 7
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/785.jpg",
+ "login": "genuine-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "likely-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "eveniet sed nesciunt",
+ "url": "http://flickering-atmosphere.com",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-02-26T15:54:42.831Z",
+ "createdAt": "2022-02-13T14:52:35.327Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/503.jpg",
+ "login": "imaginary-lion"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "lame-bird"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "saepe sequi dolore",
+ "url": "https://short-term-broccoli.org",
+ "participants": {
+ "totalCount": 2
+ },
+ "updatedAt": "2021-10-11T01:31:28.508Z",
+ "createdAt": "2022-04-11T12:41:01.234Z",
+ "comments": {
+ "totalCount": 3
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/590.jpg",
+ "login": "funny-snake"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "hollow-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "enim quia magni",
+ "url": "https://scornful-servant.name",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-03-05T19:19:15.147Z",
+ "createdAt": "2022-03-13T09:25:35.802Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/588.jpg",
+ "login": "zigzag-bear"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "growling-cow"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "omnis ut nostrum",
+ "url": "https://worthy-minority.net",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2021-08-18T19:21:40.603Z",
+ "createdAt": "2022-06-04T16:06:26.277Z",
+ "comments": {
+ "totalCount": 4
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1134.jpg",
+ "login": "composed-lion"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "overjoyed-crocodilia"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "sit est corrupti",
+ "url": "http://vital-armpit.com",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2022-07-26T22:13:34.123Z",
+ "createdAt": "2022-02-28T17:59:42.677Z",
+ "comments": {
+ "totalCount": 3
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/802.jpg",
+ "login": "sarcastic-bear"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "utilized-insect"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "illo perferendis suscipit",
+ "url": "http://snappy-skywalk.info",
+ "participants": {
+ "totalCount": 4
+ },
+ "updatedAt": "2021-10-02T21:22:35.070Z",
+ "createdAt": "2021-10-25T11:36:35.561Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/232.jpg",
+ "login": "pristine-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "adorable-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "assumenda nostrum aut",
+ "url": "http://loud-legend.info",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-06-16T22:45:23.107Z",
+ "createdAt": "2022-05-23T01:10:35.755Z",
+ "comments": {
+ "totalCount": 1
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/652.jpg",
+ "login": "wrong-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "grizzled-cow"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "qui magnam dolorem",
+ "url": "http://exhausted-horn.org",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2021-12-18T18:46:10.828Z",
+ "createdAt": "2022-03-27T06:42:04.192Z",
+ "comments": {
+ "totalCount": 7
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/838.jpg",
+ "login": "humming-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "profuse-bird"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "nostrum numquam porro",
+ "url": "https://unusual-reaction.org",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-05-11T15:14:57.433Z",
+ "createdAt": "2022-05-06T10:48:16.946Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/573.jpg",
+ "login": "prime-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "forthright-lion"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "in inventore pariatur",
+ "url": "https://cheery-accord.info",
+ "participants": {
+ "totalCount": 4
+ },
+ "updatedAt": "2022-06-30T16:22:46.819Z",
+ "createdAt": "2022-05-11T12:37:36.848Z",
+ "comments": {
+ "totalCount": 6
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1143.jpg",
+ "login": "limp-bear"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "faraway-rabbit"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "et dolores eaque",
+ "url": "http://overjoyed-former.info",
+ "participants": {
+ "totalCount": 4
+ },
+ "updatedAt": "2022-04-19T15:14:58.698Z",
+ "createdAt": "2022-04-28T23:15:49.918Z",
+ "comments": {
+ "totalCount": 1
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/347.jpg",
+ "login": "same-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "smoggy-bird"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "vel error numquam",
+ "url": "http://submissive-ladder.org",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2021-12-19T23:43:44.987Z",
+ "createdAt": "2022-01-04T11:52:01.233Z",
+ "comments": {
+ "totalCount": 4
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/951.jpg",
+ "login": "round-dog"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "humming-cetacean"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "doloribus asperiores occaecati",
+ "url": "http://webbed-underclothes.biz",
+ "participants": {
+ "totalCount": 2
+ },
+ "updatedAt": "2022-04-19T07:07:21.270Z",
+ "createdAt": "2022-04-20T10:41:00.681Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1123.jpg",
+ "login": "tepid-cat"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "colossal-bird"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "reiciendis illum quos",
+ "url": "http://rusty-repeat.com",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-08-04T06:35:11.880Z",
+ "createdAt": "2022-07-16T15:11:56.116Z",
+ "comments": {
+ "totalCount": 7
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1068.jpg",
+ "login": "grown-cat"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "superficial-lion"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "tempore et sequi",
+ "url": "https://defenseless-signal.info",
+ "participants": {
+ "totalCount": 2
+ },
+ "updatedAt": "2022-03-12T10:17:20.598Z",
+ "createdAt": "2022-05-31T20:12:43.316Z",
+ "comments": {
+ "totalCount": 7
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/468.jpg",
+ "login": "blissful-horse"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "positive-insect"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "aut minima odit",
+ "url": "https://well-groomed-octave.biz",
+ "participants": {
+ "totalCount": 4
+ },
+ "updatedAt": "2021-08-20T21:00:05.294Z",
+ "createdAt": "2022-04-11T23:51:45.727Z",
+ "comments": {
+ "totalCount": 9
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1044.jpg",
+ "login": "crowded-bird"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "unlined-horse"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "aut sunt quisquam",
+ "url": "https://realistic-student.com",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2021-11-07T11:13:39.158Z",
+ "createdAt": "2022-01-30T19:35:35.546Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/691.jpg",
+ "login": "loathsome-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "kooky-crocodilia"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "reprehenderit non eos",
+ "url": "https://alert-testimony.net",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2022-04-24T16:34:36.616Z",
+ "createdAt": "2021-11-01T21:35:35.900Z",
+ "comments": {
+ "totalCount": 5
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/691.jpg",
+ "login": "dear-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "wavy-bear"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "quis in earum",
+ "url": "https://radiant-posterior.net",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2021-09-26T08:50:55.755Z",
+ "createdAt": "2022-04-10T00:03:40.290Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/952.jpg",
+ "login": "prestigious-cat"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "quick-cetacean"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "sed ut exercitationem",
+ "url": "https://portly-clasp.name",
+ "participants": {
+ "totalCount": 3
+ },
+ "updatedAt": "2022-04-06T03:04:16.077Z",
+ "createdAt": "2022-01-14T10:22:19.619Z",
+ "comments": {
+ "totalCount": 5
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/768.jpg",
+ "login": "fatal-cetacean"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "pricey-dog"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "voluptas error sunt",
+ "url": "http://second-outcome.info",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2021-08-19T20:35:40.281Z",
+ "createdAt": "2021-10-02T12:24:16.983Z",
+ "comments": {
+ "totalCount": 6
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/283.jpg",
+ "login": "double-fish"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "liquid-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "sed reprehenderit sunt",
+ "url": "https://playful-fibrosis.com",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-05-24T19:35:19.804Z",
+ "createdAt": "2022-04-04T20:15:10.303Z",
+ "comments": {
+ "totalCount": 3
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/38.jpg",
+ "login": "separate-cat"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "jolly-bear"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "accusantium cupiditate et",
+ "url": "http://lined-flood.biz",
+ "participants": {
+ "totalCount": 9
+ },
+ "updatedAt": "2022-01-23T15:45:04.503Z",
+ "createdAt": "2021-11-29T07:20:44.372Z",
+ "comments": {
+ "totalCount": 4
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/410.jpg",
+ "login": "yummy-dog"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "pesky-bear"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "eum molestiae accusantium",
+ "url": "http://vast-grub.biz",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-05-24T12:02:33.225Z",
+ "createdAt": "2022-02-26T14:40:38.285Z",
+ "comments": {
+ "totalCount": 5
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/186.jpg",
+ "login": "unique-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "remorseful-horse"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "ea eligendi dolore",
+ "url": "http://mammoth-wick.biz",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2022-07-02T12:23:03.701Z",
+ "createdAt": "2022-06-30T03:56:08.273Z",
+ "comments": {
+ "totalCount": 4
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/605.jpg",
+ "login": "soft-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "vengeful-fish"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "eum in esse",
+ "url": "https://immaculate-liability.info",
+ "participants": {
+ "totalCount": 4
+ },
+ "updatedAt": "2022-06-14T02:40:34.008Z",
+ "createdAt": "2022-04-07T14:46:48.442Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/916.jpg",
+ "login": "wretched-cow"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "popular-insect"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "est voluptatem molestiae",
+ "url": "http://venerated-chocolate.biz",
+ "participants": {
+ "totalCount": 1
+ },
+ "updatedAt": "2021-10-08T18:02:11.052Z",
+ "createdAt": "2021-09-28T09:48:13.138Z",
+ "comments": {
+ "totalCount": 1
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/218.jpg",
+ "login": "flawless-dog"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "made-up-lion"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "sunt atque corrupti",
+ "url": "https://secondary-flight.biz",
+ "participants": {
+ "totalCount": 7
+ },
+ "updatedAt": "2021-12-31T07:36:01.831Z",
+ "createdAt": "2022-03-29T20:52:30.083Z",
+ "comments": {
+ "totalCount": 6
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1099.jpg",
+ "login": "nervous-dog"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "inconsequential-cat"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "vel quo nam",
+ "url": "https://third-week.info",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-03-06T07:42:44.049Z",
+ "createdAt": "2021-08-16T14:34:09.635Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/688.jpg",
+ "login": "dark-dog"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "modest-rabbit"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "rerum non nam",
+ "url": "http://complete-registry.net",
+ "participants": {
+ "totalCount": 5
+ },
+ "updatedAt": "2022-05-04T02:09:20.355Z",
+ "createdAt": "2022-06-08T12:23:09.380Z",
+ "comments": {
+ "totalCount": 1
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/489.jpg",
+ "login": "blond-insect"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "terrible-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "accusamus quis non",
+ "url": "https://rosy-childhood.com",
+ "participants": {
+ "totalCount": 6
+ },
+ "updatedAt": "2021-12-14T12:46:15.628Z",
+ "createdAt": "2022-02-19T00:45:57.573Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1094.jpg",
+ "login": "creative-bird"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "grotesque-dog"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "consectetur id et",
+ "url": "http://some-innocent.info",
+ "participants": {
+ "totalCount": 8
+ },
+ "updatedAt": "2021-11-27T11:12:58.067Z",
+ "createdAt": "2021-12-10T00:15:14.640Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/583.jpg",
+ "login": "drab-horse"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "acceptable-snake"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "ipsum beatae voluptas",
+ "url": "https://faithful-down.info",
+ "participants": {
+ "totalCount": 6
+ },
+ "updatedAt": "2022-08-13T17:03:33.381Z",
+ "createdAt": "2021-10-05T11:26:39.392Z",
+ "comments": {
+ "totalCount": 8
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/578.jpg",
+ "login": "esteemed-bird"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "cultured-fish"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "odio perferendis maxime",
+ "url": "https://minty-tactics.info",
+ "participants": {
+ "totalCount": 2
+ },
+ "updatedAt": "2022-01-25T02:14:59.148Z",
+ "createdAt": "2021-09-23T04:02:15.225Z",
+ "comments": {
+ "totalCount": 4
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/917.jpg",
+ "login": "quiet-bear"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "ill-fated-lion"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "sed enim laudantium",
+ "url": "https://welcome-provider.org",
+ "participants": {
+ "totalCount": 6
+ },
+ "updatedAt": "2022-02-24T23:08:19.320Z",
+ "createdAt": "2021-09-13T03:10:39.303Z",
+ "comments": {
+ "totalCount": 2
+ }
+ }
+ },
+ {
+ "node": {
+ "assignees": {
+ "edges": [
+ {
+ "node": {
+ "avatarUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/973.jpg",
+ "login": "portly-crocodilia"
+ }
+ }
+ ]
+ },
+ "author": {
+ "login": "antique-bird"
+ },
+ "repository": {
+ "nameWithOwner": "backstage/backstage"
+ },
+ "title": "enim accusamus consequuntur",
+ "url": "http://shady-impudence.net",
+ "participants": {
+ "totalCount": 2
+ },
+ "updatedAt": "2021-12-04T05:35:50.780Z",
+ "createdAt": "2021-12-21T11:44:33.940Z",
+ "comments": {
+ "totalCount": 9
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/plugins/github-issues/dev/index.tsx b/plugins/github-issues/dev/index.tsx
new file mode 100644
index 0000000000..a5f20ab9b9
--- /dev/null
+++ b/plugins/github-issues/dev/index.tsx
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2021 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 { createDevApp } from '@backstage/dev-utils';
+import {
+ CatalogApi,
+ catalogApiRef,
+ EntityProvider,
+} from '@backstage/plugin-catalog-react';
+
+import { gitHubIssuesPlugin, GitHubIssuesPage } from '../src';
+import { GitHubIssuesApi, gitHubIssuesApiRef } from '../src/api';
+
+import testData from './__fixtures__/component-issues-data.json';
+
+createDevApp()
+ .registerPlugin(gitHubIssuesPlugin)
+ .registerApi({
+ api: gitHubIssuesApiRef,
+ deps: {},
+ factory: () =>
+ ({
+ fetchIssuesByRepoFromGitHub: async () => testData,
+ } as GitHubIssuesApi),
+ })
+ .registerApi({
+ api: catalogApiRef,
+ deps: {},
+ factory: () =>
+ ({
+ getEntities: () => ({}),
+ } as CatalogApi),
+ })
+ .addPage({
+ title: 'Component Issues',
+ element: (
+
+
+
+ ),
+ })
+ .render();
diff --git a/plugins/github-issues/package.json b/plugins/github-issues/package.json
index 2ddb8481c4..d31bb52603 100644
--- a/plugins/github-issues/package.json
+++ b/plugins/github-issues/package.json
@@ -25,6 +25,7 @@
"@backstage/catalog-model": "^1.0.3",
"@backstage/core-components": "^0.11.0-next.2",
"@backstage/core-plugin-api": "^1.0.5-next.0",
+ "@backstage/errors": "^1.1.0",
"@backstage/integration": "^1.3.0-next.0",
"@backstage/plugin-catalog-react": "^1.1.3-next.2",
"@backstage/theme": "^0.2.15",
@@ -33,7 +34,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"luxon": "^2.4.0",
"octokit": "^2.0.4",
- "react-use": "^17.2.4"
+ "react-use": "^17.4.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
@@ -43,6 +44,7 @@
"@backstage/core-app-api": "^1.0.5-next.0",
"@backstage/dev-utils": "^1.0.5-next.1",
"@backstage/test-utils": "^1.1.3-next.0",
+ "@spotify/prettier-config": "^14.0.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.test.ts b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts
new file mode 100644
index 0000000000..4d1c3ffff3
--- /dev/null
+++ b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts
@@ -0,0 +1,300 @@
+/*
+ * 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.
+ */
+const mockGraphQLQuery = jest.fn(() => ({}));
+jest.mock('octokit', () => ({
+ Octokit: jest.fn(() => ({ graphql: mockGraphQLQuery })),
+}));
+
+import { ConfigApi, ErrorApi } from '@backstage/core-plugin-api';
+import { ForwardedError } from '@backstage/errors';
+import { gitHubIssuesApi } from './gitHubIssuesApi';
+
+describe('gitHubIssuesApi', () => {
+ describe('fetchIssuesByRepoFromGitHub', () => {
+ it('should call GitHub API with correct query with fragment for each repo', async () => {
+ const api = gitHubIssuesApi(
+ { getAccessToken: jest.fn() },
+ {
+ getOptionalConfigArray: jest.fn(),
+ } as unknown as ConfigApi,
+ { post: jest.fn() } as unknown as ErrorApi,
+ );
+
+ await api.fetchIssuesByRepoFromGitHub(
+ ['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
+ 10,
+ );
+ 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' +
+ ' ',
+ );
+ });
+ });
+
+ it('should return data for repos with successfully retrieved issues when GitHub returns partial failure', async () => {
+ mockGraphQLQuery.mockImplementationOnce(() =>
+ Promise.reject({
+ data: {
+ yoyo: {
+ issues: {
+ totalCount: 1,
+ edges: [
+ {
+ node: {
+ assignees: {
+ edges: [],
+ },
+ author: {
+ login: 'mrwolny',
+ },
+ repository: {
+ nameWithOwner: 'mrwolny/yo-yo',
+ },
+ title: "It's the ISSUE!",
+ url: 'https://github.com/mrwolny/yo-yo/issues/1',
+ participants: {
+ totalCount: 1,
+ },
+ updatedAt: '2022-07-04T18:47:33Z',
+ createdAt: '2022-06-23T18:14:26Z',
+ comments: {
+ totalCount: 4,
+ },
+ },
+ },
+ ],
+ },
+ },
+ notfound: null,
+ },
+ errors: [
+ {
+ type: 'NOT_FOUND',
+ path: ['notfound'],
+ locations: [
+ {
+ line: 48,
+ column: 9,
+ },
+ ],
+ message:
+ "Could not resolve to a Repository with the name 'notfound/notfound'.",
+ },
+ ],
+ }),
+ );
+
+ const api = gitHubIssuesApi(
+ { getAccessToken: jest.fn() },
+ {
+ getOptionalConfigArray: jest.fn(),
+ } as unknown as ConfigApi,
+ { post: jest.fn() } as unknown as ErrorApi,
+ );
+
+ const data = await api.fetchIssuesByRepoFromGitHub(
+ ['mrwolny/yo-yo', 'mrwolny/notfound'],
+ 10,
+ );
+
+ expect(data).toEqual({
+ 'mrwolny/yo-yo': {
+ issues: {
+ totalCount: 1,
+ edges: [
+ {
+ node: {
+ assignees: {
+ edges: [],
+ },
+ author: {
+ login: 'mrwolny',
+ },
+ repository: {
+ nameWithOwner: 'mrwolny/yo-yo',
+ },
+ title: "It's the ISSUE!",
+ url: 'https://github.com/mrwolny/yo-yo/issues/1',
+ participants: {
+ totalCount: 1,
+ },
+ updatedAt: '2022-07-04T18:47:33Z',
+ createdAt: '2022-06-23T18:14:26Z',
+ comments: {
+ totalCount: 4,
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('should return empty object when GitHub returns failure with no data', async () => {
+ mockGraphQLQuery.mockImplementationOnce(() =>
+ Promise.reject({
+ data: {
+ notfound: null,
+ },
+ errors: [
+ {
+ type: 'NOT_FOUND',
+ path: ['notfound'],
+ locations: [
+ {
+ line: 48,
+ column: 9,
+ },
+ ],
+ message:
+ "Could not resolve to a Repository with the name 'notfound/notfound'.",
+ },
+ ],
+ }),
+ );
+
+ const api = gitHubIssuesApi(
+ { getAccessToken: jest.fn() },
+ {
+ getOptionalConfigArray: jest.fn(),
+ } as unknown as ConfigApi,
+ { post: jest.fn() } as unknown as ErrorApi,
+ );
+
+ const data = await api.fetchIssuesByRepoFromGitHub(
+ ['mrwolny/notfound'],
+ 10,
+ );
+
+ expect(data).toEqual({});
+ });
+
+ it('should post error to the backstage error API when GitHub returns failure', async () => {
+ mockGraphQLQuery.mockImplementationOnce(() =>
+ Promise.reject({
+ data: {
+ notfound: null,
+ },
+ errors: [
+ {
+ type: 'NOT_FOUND',
+ path: ['notfound'],
+ locations: [
+ {
+ line: 48,
+ column: 9,
+ },
+ ],
+ message:
+ "Could not resolve to a Repository with the name 'notfound/notfound'.",
+ },
+ ],
+ }),
+ );
+
+ const mockErrorApi = { post: jest.fn() };
+
+ const api = gitHubIssuesApi(
+ { getAccessToken: jest.fn() },
+ {
+ getOptionalConfigArray: jest.fn(),
+ } as unknown as ConfigApi,
+ mockErrorApi as unknown as ErrorApi,
+ );
+
+ await api.fetchIssuesByRepoFromGitHub(['mrwolny/notfound'], 10);
+
+ expect(mockErrorApi.post).toHaveBeenCalledTimes(1);
+ expect(mockErrorApi.post).toHaveBeenCalledWith(
+ new ForwardedError('GitHub Issues Plugin failure', {
+ data: {
+ notfound: null,
+ },
+ errors: [
+ {
+ type: 'NOT_FOUND',
+ path: ['notfound'],
+ locations: [
+ {
+ line: 48,
+ column: 9,
+ },
+ ],
+ message:
+ "Could not resolve to a Repository with the name 'notfound/notfound'.",
+ },
+ ],
+ }),
+ );
+ });
+});
diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.ts b/plugins/github-issues/src/api/gitHubIssuesApi.ts
new file mode 100644
index 0000000000..816345940c
--- /dev/null
+++ b/plugins/github-issues/src/api/gitHubIssuesApi.ts
@@ -0,0 +1,217 @@
+/*
+ * 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 { Octokit } from 'octokit';
+import {
+ createApiRef,
+ ConfigApi,
+ ErrorApi,
+ OAuthApi,
+} from '@backstage/core-plugin-api';
+import { readGitHubIntegrationConfigs } from '@backstage/integration';
+import { ForwardedError } from '@backstage/errors';
+
+/** @internal */
+export type Assignee = {
+ avatarUrl: string;
+ login: string;
+};
+
+/** @internal */
+export type EdgesWithNodes = {
+ edges: Array<{
+ node: T;
+ }>;
+};
+
+/** @internal */
+export type IssueAuthor = {
+ login: string;
+};
+
+/** @internal */
+export type Issue = {
+ assignees: EdgesWithNodes;
+ author: IssueAuthor;
+ repository: {
+ nameWithOwner: string;
+ };
+ title: string;
+ url: string;
+ participants: {
+ totalCount: number;
+ };
+ createdAt: string;
+ updatedAt: string;
+ comments: {
+ totalCount: number;
+ };
+};
+
+/** @internal */
+export type RepoIssues = {
+ issues: {
+ totalCount: number;
+ } & EdgesWithNodes;
+};
+
+/** @internal */
+export type IssuesByRepo = Record;
+
+/** @internal */
+export type GitHubIssuesApi = ReturnType;
+
+/** @internal */
+export const gitHubIssuesApiRef = createApiRef({
+ id: 'plugin.githubissues.service',
+});
+
+/** @internal */
+export const gitHubIssuesApi = (
+ githubAuthApi: OAuthApi,
+ configApi: ConfigApi,
+ errorApi: ErrorApi,
+) => {
+ let octokit: Octokit;
+
+ const getOctokit = async () => {
+ const baseUrl = readGitHubIntegrationConfigs(
+ configApi.getOptionalConfigArray('integrations.github') ?? [],
+ )[0].apiBaseUrl;
+
+ const token = await githubAuthApi.getAccessToken(['repo']);
+
+ if (!octokit) {
+ octokit = new Octokit({ auth: token, ...(baseUrl && { baseUrl }) });
+ }
+
+ return octokit.graphql;
+ };
+
+ const fetchIssuesByRepoFromGitHub = async (
+ repos: Array,
+ itemsPerRepo: number,
+ ): Promise => {
+ const graphql = await getOctokit();
+ const safeNames: Array = [];
+
+ const repositories = repos.map(repo => {
+ const [owner, name] = repo.split('/');
+
+ const safeNameRegex = /-|\./gi;
+ let safeName = name.replace(safeNameRegex, '');
+
+ while (safeNames.includes(safeName)) {
+ safeName += 'x';
+ }
+
+ safeNames.push(safeName);
+
+ return {
+ safeName,
+ name,
+ owner,
+ };
+ });
+
+ let issuesByRepo: IssuesByRepo = {};
+ try {
+ issuesByRepo = await graphql(
+ createIssueByRepoQuery(repositories, itemsPerRepo),
+ );
+ } catch (e) {
+ if (e.data) {
+ issuesByRepo = e.data;
+ }
+
+ errorApi.post(new ForwardedError('GitHub Issues Plugin failure', e));
+ }
+
+ return repositories.reduce((acc, { safeName, name, owner }) => {
+ if (issuesByRepo[safeName]) {
+ acc[`${owner}/${name}`] = issuesByRepo[safeName];
+ }
+
+ return acc;
+ }, {} as IssuesByRepo);
+ };
+
+ return { fetchIssuesByRepoFromGitHub };
+};
+
+function createIssueByRepoQuery(
+ repositories: Array<{
+ safeName: string;
+ name: string;
+ owner: string;
+ }>,
+ itemsPerRepo: number,
+): string {
+ const fragment = `
+ fragment issues on Repository {
+ issues(
+ states: OPEN
+ first: ${itemsPerRepo}
+ orderBy: { field: UPDATED_AT, direction: DESC }
+ ) {
+ totalCount
+ edges {
+ node {
+ assignees(first: 10) {
+ edges {
+ node {
+ avatarUrl
+ login
+ }
+ }
+ }
+ author {
+ login
+ }
+ repository {
+ nameWithOwner
+ }
+ title
+ url
+ participants {
+ totalCount
+ }
+ updatedAt
+ createdAt
+ comments(last: 1) {
+ totalCount
+ }
+ }
+ }
+ }
+ }
+ `;
+
+ const query = `
+ ${fragment}
+
+ query {
+ ${repositories.map(
+ ({ safeName, name, owner }) => `
+ ${safeName}: repository(name: "${name}", owner: "${owner}") {
+ ...issues
+ }
+ `,
+ )}
+ }
+ `;
+
+ return query;
+}
diff --git a/plugins/github-issues/src/api/index.ts b/plugins/github-issues/src/api/index.ts
new file mode 100644
index 0000000000..4cc3372c45
--- /dev/null
+++ b/plugins/github-issues/src/api/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 * from './gitHubIssuesApi';
diff --git a/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.test.tsx b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.test.tsx
new file mode 100644
index 0000000000..33eec79359
--- /dev/null
+++ b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.test.tsx
@@ -0,0 +1,161 @@
+/*
+ * 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 from 'react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import {
+ EntityProvider,
+ catalogApiRef,
+ CatalogApi,
+} from '@backstage/plugin-catalog-react';
+import { Entity } from '@backstage/catalog-model';
+
+import { GitHubIssuesApi, gitHubIssuesApiRef, Issue } from '../../api';
+
+import { GitHubIssues } from './GitHubIssues';
+
+const getTestIssue = (overwrites: Partial = {}): { node: Issue } => ({
+ node: {
+ ...{
+ assignees: {
+ edges: [
+ {
+ node: {
+ avatarUrl:
+ 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1112.jpg',
+ login: 'worthless-horse',
+ },
+ },
+ ],
+ },
+ author: {
+ login: 'next-dog',
+ },
+ repository: {
+ nameWithOwner: 'backstage/backstage',
+ },
+ title: 'quasi labore qui',
+ url: 'http://flowery-muscatel.net',
+ participants: {
+ totalCount: 3,
+ },
+ updatedAt: '2022-05-02T09:46:35.885Z',
+ createdAt: '2022-06-03T07:11:22.320Z',
+ comments: {
+ totalCount: 6,
+ },
+ },
+ ...overwrites,
+ },
+});
+
+jest
+ .useFakeTimers()
+ .setSystemTime(new Date('2020-04-20T08:15:47.614Z').getTime());
+
+const entityComponent = {
+ metadata: {
+ annotations: {
+ 'github.com/project-slug': 'backstage/backstage',
+ },
+ name: 'backstage',
+ },
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+} as unknown as Entity;
+
+const mockCatalogApi = {
+ getEntities: () => ({}),
+} as CatalogApi;
+
+describe('GitHubIssues', () => {
+ it('should render correctly when there are no issues in GitHub', async () => {
+ const mockApi = {
+ fetchIssuesByRepoFromGitHub: async () => ({
+ backstage: {
+ issues: {
+ totalCount: 0,
+ edges: [],
+ },
+ },
+ }),
+ } as GitHubIssuesApi;
+
+ const apis = [
+ [gitHubIssuesApiRef, mockApi],
+ [catalogApiRef, mockCatalogApi],
+ ] as const;
+
+ const { getByTestId } = await renderInTestApp(
+
+
+
+
+ ,
+ );
+
+ expect(getByTestId('no-issues-msg')).toHaveTextContent(
+ 'Hurray! No Issues 🚀',
+ );
+ });
+
+ it('should render correctly', async () => {
+ const testIssue = getTestIssue({
+ createdAt: '2020-04-19T10:15:47.614Z',
+ updatedAt: '2020-04-20T00:15:47.614Z',
+ });
+
+ const mockApi = {
+ fetchIssuesByRepoFromGitHub: async () => ({
+ backstage: {
+ issues: {
+ totalCount: 1,
+ edges: [testIssue],
+ },
+ },
+ }),
+ } as GitHubIssuesApi;
+ const apis = [
+ [gitHubIssuesApiRef, mockApi],
+ [catalogApiRef, mockCatalogApi],
+ ] as const;
+
+ const { getByText, getByTestId } = await renderInTestApp(
+
+
+
+
+ ,
+ );
+
+ getByText('All repositories (1 Issue)');
+
+ expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
+ testIssue.node.title,
+ );
+
+ expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
+ testIssue.node.repository.nameWithOwner,
+ );
+
+ expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
+ `Created at: 22 hours ago by ${testIssue.node.author.login}`,
+ );
+
+ expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
+ `Last update at: 8 hours ago`,
+ );
+ });
+});
diff --git a/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx
index 5380c8eb22..574c15d3b3 100644
--- a/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx
+++ b/plugins/github-issues/src/components/GitHubIssues/GitHubIssues.tsx
@@ -20,12 +20,9 @@ import { InfoCard, Progress } from '@backstage/core-components';
import RefreshIcon from '@material-ui/icons/Refresh';
import { useEntityGitHubRepositories } from '../../hooks/useEntityGitHubRepositories';
-import {
- RepoIssues,
- useGetIssuesByRepoFromGitHub,
-} from '../../hooks/useGetIssuesByRepoFromGitHub';
+import { useGetIssuesByRepoFromGitHub } from '../../hooks/useGetIssuesByRepoFromGitHub';
-import { IssueList } from './IssuesList';
+import { IssuesList } from './IssuesList';
import { NoRepositoriesInfo } from './NoRepositoriesInfo';
/**
@@ -39,29 +36,12 @@ export type GitHubIssuesProps = {
export const GitHubIssues = (props: GitHubIssuesProps) => {
const { itemsPerPage = 10, itemsPerRepo = 40 } = props;
- const [isLoading, setIsLoading] = React.useState(true);
-
- const [issuesByRepository, setIssuesByRepository] =
- React.useState>();
-
const { repositories } = useEntityGitHubRepositories();
- const getIssues = useGetIssuesByRepoFromGitHub();
-
- const fetchGitHubIssues = React.useCallback(async () => {
- setIsLoading(true);
- const issuesByRepo = await getIssues(repositories, itemsPerRepo);
-
- setIssuesByRepository(issuesByRepo);
- setIsLoading(false);
- }, [itemsPerRepo, getIssues, repositories]);
-
- React.useEffect(() => {
- if (repositories.length) {
- fetchGitHubIssues();
- } else {
- setIsLoading(false);
- }
- }, [repositories.length, fetchGitHubIssues]);
+ const {
+ isLoading,
+ gitHubIssuesByRepo: issuesByRepository,
+ retry,
+ } = useGetIssuesByRepoFromGitHub(repositories, itemsPerRepo);
if (!repositories.length) {
return ;
@@ -72,7 +52,7 @@ export const GitHubIssues = (props: GitHubIssuesProps) => {
title={
Open GitHub Issues
-
+
@@ -80,7 +60,7 @@ export const GitHubIssues = (props: GitHubIssuesProps) => {
>
{isLoading && }
-
diff --git a/plugins/github-issues/src/components/GitHubIssues/IssueCard/IssueCard.tsx b/plugins/github-issues/src/components/GitHubIssues/IssueCard/IssueCard.tsx
index 95658fb343..ccf30a9a84 100644
--- a/plugins/github-issues/src/components/GitHubIssues/IssueCard/IssueCard.tsx
+++ b/plugins/github-issues/src/components/GitHubIssues/IssueCard/IssueCard.tsx
@@ -59,7 +59,7 @@ export const IssueCard = (props: IssueCardProps) => {
} = props;
return (
-
+
diff --git a/plugins/github-issues/src/components/GitHubIssues/IssuesList/IssuesList.tsx b/plugins/github-issues/src/components/GitHubIssues/IssuesList/IssuesList.tsx
index f279294928..b46eab22b6 100644
--- a/plugins/github-issues/src/components/GitHubIssues/IssuesList/IssuesList.tsx
+++ b/plugins/github-issues/src/components/GitHubIssues/IssuesList/IssuesList.tsx
@@ -19,14 +19,14 @@ import { Box } from '@material-ui/core';
import { Pagination } from '@material-ui/lab';
import { IssueCard } from '../IssueCard';
-import { RepoIssues } from '../../../hooks/useGetIssuesByRepoFromGitHub';
+import { IssuesByRepo } from '../../../api';
import { RepositoryFilters } from './Filters';
export type PluginMode = 'page' | 'card';
export type IssueListProps = {
itemsPerPage?: number;
- issuesByRepository?: Record;
+ issuesByRepository?: IssuesByRepo;
};
const getIssuesCountForFilterLabel = (
@@ -37,7 +37,7 @@ const getIssuesCountForFilterLabel = (
issuesAvailable < totalIssues ? '*' : ''
}`;
-export const IssueList = ({
+export const IssuesList = ({
itemsPerPage = 10,
issuesByRepository,
}: IssueListProps) => {
@@ -140,6 +140,7 @@ export const IssueList = ({
index,
) => (
Hurray! No Issues 🚀
+ Hurray! No Issues 🚀
)}
{issues.length / itemsPerPage > 1 ? (
({}));
-jest.mock('./useOctokitGraphQL', () => ({
- useOctokitGraphQL: jest.fn(() => mockGraphQLQuery),
-}));
-
-import React from 'react';
-import { render } from '@testing-library/react';
-import { useGetIssuesByRepoFromGitHub } from './useGetIssuesByRepoFromGitHub';
-
-describe('useGetIssuesBeRepoFromGitHub', () => {
- it('should call GitHub API with correct query with fragment for each repo', async () => {
- const Helper = () => {
- const getIssues = useGetIssuesByRepoFromGitHub();
-
- getIssues(['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'], 10);
-
- return ;
- };
-
- render();
-
- 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' +
- ' avatarUrl\n' +
- ' url\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' +
- ' ',
- );
- });
-});
diff --git a/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts b/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts
index 271cd6c741..8d1d0c2056 100644
--- a/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts
+++ b/plugins/github-issues/src/hooks/useGetIssuesByRepoFromGitHub.ts
@@ -13,161 +13,31 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React from 'react';
-import { useOctokitGraphQL } from './useOctokitGraphQL';
+import { useApi } from '@backstage/core-plugin-api';
-type Assignee = {
- avatarUrl: string;
- login: string;
-};
+import useAsyncRetry from 'react-use/lib/useAsyncRetry';
+import { gitHubIssuesApiRef } from '../api';
-export type EdgesWithNodes = {
- edges: Array<{
- node: T;
- }>;
-};
-
-export type Node = {
- node: T;
-};
-
-type IssueAuthor = {
- login: string;
-};
-
-export type Issue = {
- assignees: EdgesWithNodes;
- author: IssueAuthor;
- repository: {
- nameWithOwner: string;
- };
- title: string;
- url: string;
- participants: {
- totalCount: number;
- };
- createdAt: string;
- updatedAt: string;
- comments: {
- totalCount: number;
- };
-};
-
-export type RepoIssues = {
- issues: {
- totalCount: number;
- } & EdgesWithNodes;
-};
-
-export type RepoIssuesQueryResults = Record;
-
-const createQuery = (
- repositories: Array<{
- safeName: string;
- name: string;
- owner: string;
- }>,
+export const useGetIssuesByRepoFromGitHub = (
+ repos: Array,
itemsPerRepo: number,
-): string => {
- const fragment = `
- fragment issues on Repository {
- issues(
- states: OPEN
- first: ${itemsPerRepo}
- orderBy: { field: UPDATED_AT, direction: DESC }
- ) {
- totalCount
- edges {
- node {
- assignees(first: 10) {
- edges {
- node {
- avatarUrl
- login
- }
- }
- }
- author {
- login
- avatarUrl
- url
- }
- repository {
- nameWithOwner
- }
- title
- url
- participants {
- totalCount
- }
- updatedAt
- createdAt
- comments(last: 1) {
- totalCount
- }
- }
- }
- }
- }
- `;
+) => {
+ const gitHubIssuesApi = useApi(gitHubIssuesApiRef);
- const query = `
- ${fragment}
-
- query {
- ${repositories.map(
- ({ safeName, name, owner }) => `
- ${safeName}: repository(name: "${name}", owner: "${owner}") {
- ...issues
- }
- `,
- )}
- }
- `;
-
- return query;
-};
-
-export const useGetIssuesByRepoFromGitHub = () => {
- const graphql = useOctokitGraphQL();
-
- const fn = React.useRef(
- async (
- repos: Array,
- itemsPerRepo: number,
- ): Promise> => {
- const safeNames: Array = [];
-
- const repositories = repos.map(repo => {
- const [owner, name] = repo.split('/');
-
- const safeNameRegex = /-|\./gi;
- let safeName = name.replace(safeNameRegex, '');
-
- while (safeNames.includes(safeName)) {
- safeName += 'x';
- }
-
- safeNames.push(safeName);
-
- return {
- safeName,
- name,
- owner,
- };
- });
-
- const issuesByRepo: RepoIssuesQueryResults = await graphql(
- createQuery(repositories, itemsPerRepo),
+ const {
+ value: issues,
+ loading: isLoading,
+ retry,
+ } = useAsyncRetry(async () => {
+ if (repos.length > 0) {
+ return await gitHubIssuesApi.fetchIssuesByRepoFromGitHub(
+ repos,
+ itemsPerRepo,
);
+ }
- return repositories.reduce((acc, { safeName, name, owner }) => {
- acc[`${owner}/${name}`] = issuesByRepo[safeName];
+ return {};
+ }, [repos]);
- return acc;
- }, {} as Record);
- },
- );
-
- return fn.current;
+ return { isLoading, gitHubIssuesByRepo: issues, retry };
};
diff --git a/plugins/github-issues/src/hooks/useOctokitGraphQL.ts b/plugins/github-issues/src/hooks/useOctokitGraphQL.ts
deleted file mode 100644
index 6b783efd6f..0000000000
--- a/plugins/github-issues/src/hooks/useOctokitGraphQL.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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 { Octokit } from 'octokit';
-import {
- useApi,
- githubAuthApiRef,
- configApiRef,
-} from '@backstage/core-plugin-api';
-import { readGitHubIntegrationConfigs } from '@backstage/integration';
-
-let octokit: Octokit;
-
-export const useOctokitGraphQL = () => {
- const auth = useApi(githubAuthApiRef);
- const config = useApi(configApiRef);
-
- const baseUrl = readGitHubIntegrationConfigs(
- config.getOptionalConfigArray('integrations.github') ?? [],
- )[0].apiBaseUrl;
-
- return (path: string, options?: any): Promise =>
- auth
- .getAccessToken(['repo'])
- .then((token: string) => {
- if (!octokit) {
- octokit = new Octokit({ auth: token, ...(baseUrl && { baseUrl }) });
- }
-
- return octokit;
- })
- .then(octokitInstance => {
- return octokitInstance.graphql(path, options);
- });
-};
diff --git a/plugins/github-issues/src/plugin.ts b/plugins/github-issues/src/plugin.ts
index ece3fd09a2..3d845f4adf 100644
--- a/plugins/github-issues/src/plugin.ts
+++ b/plugins/github-issues/src/plugin.ts
@@ -15,15 +15,32 @@
*/
import {
createPlugin,
+ createApiFactory,
createComponentExtension,
createRoutableExtension,
+ configApiRef,
+ errorApiRef,
+ githubAuthApiRef,
} from '@backstage/core-plugin-api';
+import { gitHubIssuesApi, gitHubIssuesApiRef } from './api';
import { rootRouteRef } from './routes';
/** @public */
export const gitHubIssuesPlugin = createPlugin({
id: 'github-issues',
+ apis: [
+ createApiFactory({
+ api: gitHubIssuesApiRef,
+ deps: {
+ configApi: configApiRef,
+ githubAuthApi: githubAuthApiRef,
+ errorApi: errorApiRef,
+ },
+ factory: ({ configApi, githubAuthApi, errorApi }) =>
+ gitHubIssuesApi(githubAuthApi, configApi, errorApi),
+ }),
+ ],
routes: {
root: rootRouteRef,
},
diff --git a/yarn.lock b/yarn.lock
index efa2a9e454..de5a33bf49 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -22401,7 +22401,7 @@ react-universal-interface@^0.6.2:
resolved "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b"
integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==
-react-use@^17.2.4, react-use@^17.3.1, react-use@^17.3.2:
+react-use@^17.2.4, react-use@^17.3.1, react-use@^17.3.2, react-use@^17.4.0:
version "17.4.0"
resolved "https://registry.npmjs.org/react-use/-/react-use-17.4.0.tgz#cefef258b0a6c534a5c8021c2528ac6e1a4cdc6d"
integrity sha512-TgbNTCA33Wl7xzIJegn1HndB4qTS9u03QUwyNycUnXaweZkE4Kq2SB+Yoxx8qbshkZGYBDvUXbXWRUmQDcZZ/Q==