diff --git a/src/__mocks__/mockedData.ts b/src/__mocks__/mockedData.ts index 24f87f73c..cfa1ef9bc 100644 --- a/src/__mocks__/mockedData.ts +++ b/src/__mocks__/mockedData.ts @@ -497,83 +497,3 @@ export const mockedGraphQLResponse: GraphQLSearch = }, }, }; - -export const mockedDiscussionNotifications = [ - { - id: 1, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is an answered discussion', - type: 'Discussion', - }, - }, - { - id: 2, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is a duplicate discussion', - type: 'Discussion', - }, - }, - { - id: 3, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is an open discussion', - type: 'Discussion', - }, - }, - { - id: 4, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is nm outdated discussion', - type: 'Discussion', - }, - }, - { - id: 5, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is a reopened discussion', - type: 'Discussion', - }, - }, - { - id: 6, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is a resolved discussion', - type: 'Discussion', - }, - }, - { - id: 7, - updated_at: '2024-02-26T00:00:00Z', - repository: { - full_name: 'some/repo', - }, - subject: { - title: 'This is a default discussion', - type: 'Discussion', - }, - }, -]; diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts index 4285d006a..42c07b6ba 100644 --- a/src/hooks/useNotifications.test.ts +++ b/src/hooks/useNotifications.test.ts @@ -3,10 +3,7 @@ import axios from 'axios'; import nock from 'nock'; import { mockAccounts, mockSettings } from '../__mocks__/mock-state'; -import { - mockedDiscussionNotifications, - mockedUser, -} from '../__mocks__/mockedData'; +import { mockedUser } from '../__mocks__/mockedData'; import { AuthState } from '../types'; import { useNotifications } from './useNotifications'; @@ -195,23 +192,27 @@ describe('hooks/useNotifications.ts', () => { { id: 1, subject: { - title: 'This is a notification.', - type: 'Issue', + title: 'This is a Discussion.', + type: 'Discussion', url: 'https://api.github.com/1', }, + repository: { + full_name: 'some/repo', + }, + updated_at: '2024-02-26T00:00:00Z', }, { id: 2, subject: { - title: 'A merged PR.', - type: 'PullRequest', + title: 'This is an Issue.', + type: 'Issue', url: 'https://api.github.com/2', }, }, { id: 3, subject: { - title: 'A closed PR.', + title: 'This is a Pull Request.', type: 'PullRequest', url: 'https://api.github.com/3', }, @@ -219,83 +220,17 @@ describe('hooks/useNotifications.ts', () => { { id: 4, subject: { - title: 'A draft PR.', - type: 'PullRequest', + title: 'This is an invitation.', + type: 'RepositoryInvitation', url: 'https://api.github.com/4', }, }, - { - id: 5, - subject: { - title: 'A draft PR.', - type: 'PullRequest', - url: 'https://api.github.com/5', - }, - }, ]; nock('https://api.github.com') .get('/notifications?participating=false') .reply(200, notifications); - nock('https://api.github.com').get('/1').reply(200, { state: 'open' }); - nock('https://api.github.com') - .get('/2') - .reply(200, { state: 'closed', merged: true }); - nock('https://api.github.com') - .get('/3') - .reply(200, { state: 'closed', merged: false }); - nock('https://api.github.com') - .get('/4') - .reply(200, { state: 'open', draft: false }); - nock('https://api.github.com') - .get('/5') - .reply(200, { state: 'open', draft: true }); - - const { result } = renderHook(() => useNotifications(true)); - - act(() => { - result.current.fetchNotifications(accounts, { - ...mockSettings, - colors: true, - }); - }); - - expect(result.current.isFetching).toBe(true); - - await waitFor(() => { - expect(result.current.notifications[0].hostname).toBe('github.com'); - }); - - expect(result.current.notifications[0].notifications.length).toBe(5); - expect( - result.current.notifications[0].notifications[0].subject.state, - ).toBe('open'); - expect( - result.current.notifications[0].notifications[1].subject.state, - ).toBe('merged'); - expect( - result.current.notifications[0].notifications[2].subject.state, - ).toBe('closed'); - expect( - result.current.notifications[0].notifications[3].subject.state, - ).toBe('open'); - expect( - result.current.notifications[0].notifications[4].subject.state, - ).toBe('draft'); - }); - - it('should fetch discussion notifications with success - with colors', async () => { - const accounts: AuthState = { - ...mockAccounts, - enterpriseAccounts: [], - user: mockedUser, - }; - - nock('https://api.github.com') - .get('/notifications?participating=false') - .reply(200, mockedDiscussionNotifications); - nock('https://api.github.com') .post('/graphql') .reply(200, { @@ -313,117 +248,19 @@ describe('hooks/useNotifications.ts', () => { ], }, }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'This is a duplicate discussion', - viewerSubscription: 'SUBSCRIBED', - stateReason: 'DUPLICATE', - isAnswered: false, - }, - }, - ], - }, - }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'This is an open discussion', - viewerSubscription: 'SUBSCRIBED', - stateReason: null, - isAnswered: false, - }, - }, - { - node: { - title: 'This is an open discussion', - viewerSubscription: 'IGNORED', - stateReason: null, - isAnswered: false, - }, - }, - ], - }, - }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'This is nm outdated discussion', - viewerSubscription: 'SUBSCRIBED', - stateReason: 'OUTDATED', - isAnswered: false, - }, - }, - ], - }, - }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'This is a reopened discussion', - viewerSubscription: 'SUBSCRIBED', - stateReason: 'REOPENED', - isAnswered: false, - }, - }, - ], - }, - }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'This is a resolved discussion', - viewerSubscription: 'SUBSCRIBED', - stateReason: 'RESOLVED', - isAnswered: false, - }, - }, - ], - }, - }, - }) - .post('/graphql') - .reply(200, { - data: { - search: { - edges: [ - { - node: { - title: 'unknown search result', - viewerSubscription: 'SUBSCRIBED', - stateReason: null, - isAnswered: false, - }, - }, - ], - }, - }, }); + nock('https://api.github.com') + .get('/2') + .reply(200, { state: 'closed', merged: true }); + nock('https://api.github.com') + .get('/3') + .reply(200, { state: 'closed', merged: false }); + nock('https://api.github.com') + .get('/4') + .reply(200, { state: 'open', draft: false }); + nock('https://api.github.com') + .get('/5') + .reply(200, { state: 'open', draft: true }); const { result } = renderHook(() => useNotifications(true)); @@ -440,26 +277,7 @@ describe('hooks/useNotifications.ts', () => { expect(result.current.notifications[0].hostname).toBe('github.com'); }); - const resultNotifications = result.current.notifications[0]; - - expect(resultNotifications.notifications.length).toBe(7); - expect(resultNotifications.notifications[0].subject.state).toBe( - 'ANSWERED', - ); - expect(resultNotifications.notifications[1].subject.state).toBe( - 'DUPLICATE', - ); - expect(resultNotifications.notifications[2].subject.state).toBe('OPEN'); - expect(resultNotifications.notifications[3].subject.state).toBe( - 'OUTDATED', - ); - expect(resultNotifications.notifications[4].subject.state).toBe( - 'REOPENED', - ); - expect(resultNotifications.notifications[5].subject.state).toBe( - 'RESOLVED', - ); - expect(resultNotifications.notifications[6].subject.state).toBe('OPEN'); + expect(result.current.notifications[0].notifications.length).toBe(4); }); }); }); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 4d875a44a..82ca9f4df 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -9,7 +9,6 @@ import { getEnterpriseAccountToken, generateGitHubAPIUrl, isEnterpriseHost, - getDiscussionState, } from '../utils/helpers'; import { removeNotification } from '../utils/remove-notification'; import { @@ -18,6 +17,7 @@ import { } from '../utils/notifications'; import Constants from '../utils/constants'; import { removeNotifications } from '../utils/remove-notifications'; +import { getNotificationState } from '../utils/state'; interface NotificationsState { notifications: AccountNotifications[]; @@ -141,47 +141,18 @@ export const useNotifications = (colors: boolean): NotificationsState => { ) : accounts.token; - switch (notification.subject.type) { - case 'Discussion': - const discussionState = await getDiscussionState( - notification, - token, - ); - - return { - ...notification, - subject: { - ...notification.subject, - state: discussionState, - }, - }; - case 'Issue': - case 'PullRequest': - const cardinalData = ( - await apiRequestAuth( - notification.subject.url, - 'GET', - token, - ) - ).data; - - const state = - cardinalData.state === 'closed' - ? cardinalData.state_reason || - (cardinalData.merged && 'merged') || - 'closed' - : (cardinalData.draft && 'draft') || 'open'; - - return { - ...notification, - subject: { - ...notification.subject, - state, - }, - }; - default: - return notification; - } + const notificationState = await getNotificationState( + notification, + token, + ); + + return { + ...notification, + subject: { + ...notification.subject, + state: notificationState, + }, + }; }, ), ), diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index d343f7b13..2ccdbe9b4 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -3,8 +3,6 @@ import { Notification, GraphQLSearch, DiscussionCommentEdge, - DiscussionStateType, - DiscussionStateSearchResultEdge, DiscussionSearchResultEdge, } from '../typesGithub'; import { apiRequestAuth } from '../utils/api-requests'; @@ -92,7 +90,6 @@ async function getDiscussionUrl( viewerSubscription title url - stateReason comments(last: 100) { edges { node { @@ -139,53 +136,6 @@ async function getDiscussionUrl( return url; } -export async function getDiscussionState( - notification: Notification, - token: string, -): Promise { - const response: GraphQLSearch = - await apiRequestAuth(`https://api.github.com/graphql`, 'POST', token, { - query: `{ - search(query:"${formatSearchQueryString( - notification.repository.full_name, - notification.subject.title, - notification.updated_at, - )}", type: DISCUSSION, first: 10) { - edges { - node { - ... on Discussion { - viewerSubscription - title - stateReason - isAnswered - } - } - } - } - }`, - }); - let edges = - response?.data?.data?.search?.edges?.filter( - (edge) => edge.node.title === notification.subject.title, - ) || []; - if (edges.length > 1) - edges = edges.filter( - (edge) => edge.node.viewerSubscription === 'SUBSCRIBED', - ); - - if (edges[0]) { - if (edges[0].node.isAnswered) { - return 'ANSWERED'; - } - - if (edges[0].node.stateReason) { - return edges[0].node.stateReason; - } - } - - return 'OPEN'; -} - export const getLatestDiscussionCommentId = ( comments: DiscussionCommentEdge[], ) => diff --git a/src/utils/state.test.ts b/src/utils/state.test.ts new file mode 100644 index 000000000..8cc7c06b0 --- /dev/null +++ b/src/utils/state.test.ts @@ -0,0 +1,402 @@ +import axios from 'axios'; +import nock from 'nock'; + +import { mockAccounts } from '../__mocks__/mock-state'; +import { mockedSingleNotification } from '../__mocks__/mockedData'; +import { + getDiscussionState, + getIssueState, + getPullRequestState, +} from './state'; +describe('utils/state.ts', () => { + beforeEach(() => { + // axios will default to using the XHR adapter which can't be intercepted + // by nock. So, configure axios to use the node adapter. + axios.defaults.adapter = 'http'; + }); + + describe('getDiscussionState', () => { + it('answered discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is an answered discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is an answered discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: null, + isAnswered: true, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('ANSWERED'); + }); + + it('duplicate discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is a duplicate discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is a duplicate discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: 'DUPLICATE', + isAnswered: false, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('DUPLICATE'); + }); + + it('open discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is an open discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is an open discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: null, + isAnswered: false, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('OPEN'); + }); + + it('outdated discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is an outdated discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is an outdated discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: 'OUTDATED', + isAnswered: false, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('OUTDATED'); + }); + + it('reopened discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is a reopened discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is a reopened discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: 'REOPENED', + isAnswered: false, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('REOPENED'); + }); + + it('resolved discussion state', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is a resolved discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is a resolved discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: 'RESOLVED', + isAnswered: false, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('RESOLVED'); + }); + + it('filtered response by subscribed', async () => { + const mockNotification = { + ...mockedSingleNotification, + subject: { + ...mockedSingleNotification.subject, + title: 'This is a discussion', + }, + }; + + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + edges: [ + { + node: { + title: 'This is a discussion', + viewerSubscription: 'SUBSCRIBED', + stateReason: null, + isAnswered: false, + }, + }, + { + node: { + title: 'This is a discussion', + viewerSubscription: 'IGNORED', + stateReason: null, + isAnswered: true, + }, + }, + ], + }, + }, + }); + + const result = await getDiscussionState( + mockNotification, + mockAccounts.token, + ); + + expect(result).toBe('OPEN'); + }); + + it('handles unknown or missing results', async () => {}); + }); + + describe('getIssueState', () => { + it('open issue state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open' }); + + const result = await getIssueState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('open'); + }); + + it('closed issue state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'closed' }); + + const result = await getIssueState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('closed'); + }); + + it('completed issue state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'closed', state_reason: 'completed' }); + + const result = await getIssueState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('completed'); + }); + + it('not_planned issue state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open', state_reason: 'not_planned' }); + + const result = await getIssueState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('not_planned'); + }); + + it('reopened issue state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open', state_reason: 'reopened' }); + + const result = await getIssueState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('reopened'); + }); + }); + + describe('getPullRequestState', () => { + it('closed pull request state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'closed', draft: false, merged: false }); + + const result = await getPullRequestState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('closed'); + }); + + it('draft pull request state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open', draft: true, merged: false }); + + const result = await getPullRequestState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('draft'); + }); + + it('merged pull request state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open', draft: false, merged: true }); + + const result = await getPullRequestState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('merged'); + }); + + it('open pull request state', async () => { + nock('https://api.github.com') + .get('/repos/manosim/notifications-test/issues/1') + .reply(200, { state: 'open', draft: false, merged: false }); + + const result = await getPullRequestState( + mockedSingleNotification, + mockAccounts.token, + ); + + expect(result).toBe('open'); + }); + }); +}); diff --git a/src/utils/state.ts b/src/utils/state.ts new file mode 100644 index 000000000..fed46baa9 --- /dev/null +++ b/src/utils/state.ts @@ -0,0 +1,103 @@ +import { formatSearchQueryString } from './helpers'; +import { + DiscussionStateSearchResultEdge, + DiscussionStateType, + GraphQLSearch, + IssueStateType, + Notification, + PullRequestStateType, + StateType, +} from '../typesGithub'; +import { apiRequestAuth } from './api-requests'; + +export async function getNotificationState( + notification: Notification, + token: string, +): Promise { + switch (notification.subject.type) { + case 'Discussion': + return await getDiscussionState(notification, token); + case 'Issue': + return await getIssueState(notification, token); + case 'PullRequest': + return await getPullRequestState(notification, token); + default: + return null; + } +} + +export async function getDiscussionState( + notification: Notification, + token: string, +): Promise { + const response: GraphQLSearch = + await apiRequestAuth(`https://api.github.com/graphql`, 'POST', token, { + query: `{ + search(query:"${formatSearchQueryString( + notification.repository.full_name, + notification.subject.title, + notification.updated_at, + )}", type: DISCUSSION, first: 10) { + edges { + node { + ... on Discussion { + viewerSubscription + title + stateReason + isAnswered + } + } + } + } + }`, + }); + + let edges = + response?.data?.data?.search?.edges?.filter( + (edge) => edge.node.title === notification.subject.title, + ) || []; + + if (edges.length > 1) { + edges = edges.filter( + (edge) => edge.node.viewerSubscription === 'SUBSCRIBED', + ); + } + + if (edges[0]) { + if (edges[0].node.isAnswered) { + return 'ANSWERED'; + } + + if (edges[0].node.stateReason) { + return edges[0].node.stateReason; + } + } + + return 'OPEN'; +} + +export async function getIssueState( + notification: Notification, + token: string, +): Promise { + const issue = (await apiRequestAuth(notification.subject.url, 'GET', token)) + .data; + + return issue.state_reason ?? issue.state; +} + +export async function getPullRequestState( + notification: Notification, + token: string, +): Promise { + const pr = (await apiRequestAuth(notification.subject.url, 'GET', token)) + .data; + + if (pr.merged) { + return 'merged'; + } else if (pr.draft) { + return 'draft'; + } + + return pr.state; +}