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==