diff --git a/src/renderer/__mocks__/user-mocks.ts b/src/renderer/__mocks__/user-mocks.ts index 3cb316d3d..28af4f4ba 100644 --- a/src/renderer/__mocks__/user-mocks.ts +++ b/src/renderer/__mocks__/user-mocks.ts @@ -4,7 +4,7 @@ import type { User } from '../typesGitHub'; export const mockGitifyUser: GitifyUser = { login: 'octocat', name: 'Mona Lisa Octocat', - id: 123456789, + id: '123456789', avatar: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, }; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index fd5fb54ae..e5c37d0c5 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -204,7 +204,7 @@ export interface GitifyUser { login: string; name: string | null; avatar: Link | null; - id: number; + id: string; } export interface GitifyError { diff --git a/src/renderer/typesGitHub.ts b/src/renderer/typesGitHub.ts index 15f810b49..7f3eb9551 100644 --- a/src/renderer/typesGitHub.ts +++ b/src/renderer/typesGitHub.ts @@ -65,39 +65,6 @@ interface GitHubSubject { type: SubjectType; } -export type UserDetails = User & UserProfile; - -export interface UserProfile { - name: string; - company: string; - blog: string; - location: string; - email: string; - hireable: string; - bio: string; - twitter_username: string; - public_repos: number; - public_gists: number; - followers: number; - following: number; - created_at: string; - updated_at: string; - private_gists: number; - total_private_repos: number; - owned_private_repos: number; - disk_usage: number; - collaborators: number; - two_factor_authentication: boolean; - plan: Plan; -} - -export interface Plan { - name: string; - space: number; - private_repos: number; - collaborators: number; -} - export interface User { login: string; id: number; diff --git a/src/renderer/utils/api/client.test.ts b/src/renderer/utils/api/client.test.ts index 07f000a78..0b16d0467 100644 --- a/src/renderer/utils/api/client.test.ts +++ b/src/renderer/utils/api/client.test.ts @@ -9,7 +9,6 @@ import { mockNonCachedAuthHeaders, } from './__mocks__/request-mocks'; import { - getAuthenticatedUser, getHtmlUrl, headNotifications, ignoreNotificationThreadSubscription, @@ -29,17 +28,6 @@ describe('renderer/utils/api/client.ts', () => { jest.clearAllMocks(); }); - it('getAuthenticatedUser - should fetch authenticated user', async () => { - await getAuthenticatedUser(mockGitHubHostname, mockToken); - - expect(axios).toHaveBeenCalledWith({ - url: 'https://api.github.com/user', - headers: mockAuthHeaders, - method: 'GET', - data: {}, - }); - }); - it('headNotifications - should fetch notifications head', async () => { await headNotifications(mockGitHubHostname, mockToken); diff --git a/src/renderer/utils/api/client.ts b/src/renderer/utils/api/client.ts index 46761fed4..5187108cf 100644 --- a/src/renderer/utils/api/client.ts +++ b/src/renderer/utils/api/client.ts @@ -14,11 +14,12 @@ import type { Notification, NotificationThreadSubscription, Release, - UserDetails, } from '../../typesGitHub'; import { isAnsweredDiscussionFeatureSupported } from '../features'; import { rendererLogError } from '../logger'; import { + FetchAuthenticatedUserDetailsDocument, + type FetchAuthenticatedUserDetailsQuery, FetchDiscussionByNumberDocument, type FetchDiscussionByNumberQuery, FetchIssueByNumberDocument, @@ -26,28 +27,17 @@ import { FetchPullRequestByNumberDocument, type FetchPullRequestByNumberQuery, } from './graphql/generated/graphql'; -import { apiRequestAuth, performGraphQLRequest } from './request'; +import { + apiRequestAuth, + type ExecutionResultWithHeaders, + performGraphQLRequest, +} from './request'; import { getGitHubAPIBaseUrl, getGitHubGraphQLUrl, getNumberFromUrl, } from './utils'; -/** - * Get the authenticated user - * - * Endpoint documentation: https://docs.github.com/en/rest/users/users#get-the-authenticated-user - */ -export function getAuthenticatedUser( - hostname: Hostname, - token: Token, -): AxiosPromise { - const url = getGitHubAPIBaseUrl(hostname); - url.pathname += 'user'; - - return apiRequestAuth(url.toString() as Link, 'GET', token); -} - /** * Perform a HEAD operation, used to validate that connectivity is established. * @@ -187,6 +177,22 @@ export async function getHtmlUrl(url: Link, token: Token): Promise { } } +/** + * Fetch details of the currently authenticated GitHub user. + */ +export async function fetchAuthenticatedUserDetails( + hostname: Hostname, + token: Token, +): Promise> { + const url = getGitHubGraphQLUrl(hostname); + + return performGraphQLRequest( + url.toString() as Link, + token, + FetchAuthenticatedUserDetailsDocument, + ); +} + /** * Fetch GitHub Issue by Issue Number. */ diff --git a/src/renderer/utils/api/graphql/generated/gql.ts b/src/renderer/utils/api/graphql/generated/gql.ts index 9fbfe2750..bc49c280c 100644 --- a/src/renderer/utils/api/graphql/generated/gql.ts +++ b/src/renderer/utils/api/graphql/generated/gql.ts @@ -19,12 +19,14 @@ type Documents = { "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": typeof types.FetchDiscussionByNumberDocument, "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": typeof types.FetchIssueByNumberDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": typeof types.FetchPullRequestByNumberDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, }; const documents: Documents = { "fragment AuthorFields on Actor {\n login\n html_url: url\n avatar_url: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": types.AuthorFieldsFragmentDoc, "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": types.FetchDiscussionByNumberDocument, "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": types.FetchIssueByNumberDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": types.FetchPullRequestByNumberDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, }; /** @@ -43,6 +45,10 @@ export function graphql(source: "query FetchIssueByNumber($owner: String!, $name * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}"): typeof import('./graphql').FetchPullRequestByNumberDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}"): typeof import('./graphql').FetchAuthenticatedUserDetailsDocument; export function graphql(source: string) { diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index 180658259..8ea49cf77 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -36083,6 +36083,11 @@ export type PullRequestReviewFieldsFragment = { __typename?: 'PullRequestReview' | { __typename?: 'User', login: string } | null }; +export type FetchAuthenticatedUserDetailsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FetchAuthenticatedUserDetailsQuery = { __typename?: 'Query', viewer: { __typename?: 'User', id: string, name?: string | null, login: string, avatarUrl: any } }; + export class TypedDocumentString extends String implements DocumentTypeDecoration @@ -36468,4 +36473,14 @@ fragment PullRequestReviewFields on PullRequestReview { author { login } -}`) as unknown as TypedDocumentString; \ No newline at end of file +}`) as unknown as TypedDocumentString; +export const FetchAuthenticatedUserDetailsDocument = new TypedDocumentString(` + query FetchAuthenticatedUserDetails { + viewer { + id + name + login + avatarUrl + } +} + `) as unknown as TypedDocumentString; \ No newline at end of file diff --git a/src/renderer/utils/api/graphql/user.graphql b/src/renderer/utils/api/graphql/user.graphql new file mode 100644 index 000000000..a69b914f6 --- /dev/null +++ b/src/renderer/utils/api/graphql/user.graphql @@ -0,0 +1,8 @@ +query FetchAuthenticatedUserDetails { + viewer { + id + name + login + avatarUrl + } +} diff --git a/src/renderer/utils/api/request.ts b/src/renderer/utils/api/request.ts index f2e23cbdc..035e2e396 100644 --- a/src/renderer/utils/api/request.ts +++ b/src/renderer/utils/api/request.ts @@ -11,6 +11,13 @@ import { rendererLogError } from '../logger'; import type { TypedDocumentString } from './graphql/generated/graphql'; import { getNextURLFromLinkHeader } from './utils'; +/** + * ExecutionResult with HTTP response headers + */ +export type ExecutionResultWithHeaders = ExecutionResult & { + headers: Record; +}; + /** * Perform an unauthenticated API request * @@ -107,8 +114,11 @@ export async function performGraphQLRequest( }, headers: headers, }).then((response) => { - return response.data; - }) as Promise>; + return { + ...response.data, + headers: response.headers, + }; + }) as Promise>; } /** diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 384b12bea..4af98511c 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,6 +1,5 @@ import type { AxiosResponse } from 'axios'; import axios from 'axios'; -import nock from 'nock'; import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks'; import { mockAuth } from '../../__mocks__/state-mocks'; @@ -16,12 +15,16 @@ import type { Token, } from '../../types'; import * as comms from '../../utils/comms'; +import * as apiClient from '../api/client'; +import type { FetchAuthenticatedUserDetailsQuery } from '../api/graphql/generated/graphql'; import * as apiRequests from '../api/request'; import * as logger from '../logger'; import type { AuthMethod } from './types'; import * as authUtils from './utils'; import { getNewOAuthAppURL, getNewTokenURL } from './utils'; +type UserDetailsResponse = FetchAuthenticatedUserDetailsQuery['viewer']; + describe('renderer/utils/auth/utils.ts', () => { describe('authGitHub', () => { jest.spyOn(logger, 'rendererLogInfo').mockImplementation(); @@ -139,6 +142,10 @@ describe('renderer/utils/auth/utils.ts', () => { describe('addAccount', () => { let mockAuthState: AuthState; + const fetchAuthenticatedUserDetailsSpy = jest.spyOn( + apiClient, + 'fetchAuthenticatedUserDetails', + ); beforeEach(() => { mockAuthState = { @@ -152,13 +159,17 @@ describe('renderer/utils/auth/utils.ts', () => { describe('should add GitHub Cloud account', () => { beforeEach(() => { - nock('https://api.github.com') - .get('/user') - .reply( - 200, - { ...mockGitifyUser, avatar_url: mockGitifyUser.avatar }, - { 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED }, - ); + fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ + data: { + viewer: { + ...mockGitifyUser, + avatarUrl: mockGitifyUser.avatar, + } as UserDetailsResponse, + }, + headers: { + 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), + }, + }); }); it('should add personal access token account', async () => { @@ -206,16 +217,18 @@ describe('renderer/utils/auth/utils.ts', () => { describe('should add GitHub Enterprise Server account', () => { beforeEach(() => { - nock('https://github.gitify.io/api/v3') - .get('/user') - .reply( - 200, - { ...mockGitifyUser, avatar_url: mockGitifyUser.avatar }, - { - 'x-github-enterprise-version': '3.0.0', - 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED, - }, - ); + fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ + data: { + viewer: { + ...mockGitifyUser, + avatarUrl: mockGitifyUser.avatar, + } as UserDetailsResponse, + }, + headers: { + 'x-github-enterprise-version': '3.0.0', + 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), + }, + }); }); it('should add personal access token account', async () => { diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 42f59aeef..b9167bec0 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -9,13 +9,11 @@ import type { AuthCode, AuthState, ClientID, - GitifyUser, Hostname, Link, Token, } from '../../types'; -import type { UserDetails } from '../../typesGitHub'; -import { getAuthenticatedUser } from '../api/client'; +import { fetchAuthenticatedUserDetails } from '../api/client'; import { apiRequest } from '../api/request'; import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; @@ -73,21 +71,6 @@ export function authGitHub( }); } -export async function getUserData( - token: Token, - hostname: Hostname, -): Promise { - const response: UserDetails = (await getAuthenticatedUser(hostname, token)) - .data; - - return { - id: response.id, - login: response.login, - name: response.name, - avatar: response.avatar_url, - }; -} - export async function getToken( authCode: AuthCode, authOptions = Constants.DEFAULT_AUTH_OPTIONS, @@ -156,21 +139,25 @@ export function removeAccount(auth: AuthState, account: Account): AuthState { export async function refreshAccount(account: Account): Promise { try { - const res = await getAuthenticatedUser(account.hostname, account.token); + const response = await fetchAuthenticatedUserDetails( + account.hostname, + account.token, + ); + const user = response.data.viewer; // Refresh user data account.user = { - id: res.data.id, - login: res.data.login, - name: res.data.name, - avatar: res.data.avatar_url, + id: user.id, + login: user.login, + name: user.name, + avatar: user.avatarUrl, }; account.version = extractHostVersion( - res.headers['x-github-enterprise-version'], + response.headers['x-github-enterprise-version'], ); - const accountScopes = res.headers['x-oauth-scopes'] + const accountScopes = response.headers['x-oauth-scopes'] ?.split(',') .map((scope: string) => scope.trim());