From cdd0a0994b902a07fcc3245a5584ac93ba213f78 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 17 Aug 2025 15:58:19 -0400 Subject: [PATCH 01/11] refactor: notification handlers Signed-off-by: Adam Setch --- .../notifications/NotificationRow.tsx | 10 +- .../utils/__snapshots__/icons.test.ts.snap | 2 +- src/renderer/utils/helpers.ts | 8 +- src/renderer/utils/icons.test.ts | 892 ++--- src/renderer/utils/icons.ts | 89 - .../notifications/handlers/checkSuite.ts | 102 + .../utils/notifications/handlers/commit.ts | 54 + .../utils/notifications/handlers/default.ts | 27 + .../notifications/handlers/discussion.ts | 128 + .../utils/notifications/handlers/index.ts | 52 + .../utils/notifications/handlers/issue.ts | 81 + .../notifications/handlers/pullRequest.ts | 175 + .../utils/notifications/handlers/release.ts | 47 + .../repositoryDependabotAlertsThread copy.ts | 32 + .../handlers/repositoryInvitation.ts | 28 + .../handlers/repositoryVulnerabilityAlert.ts | 29 + .../utils/notifications/handlers/types.ts | 32 + .../utils/notifications/handlers/utils.ts | 25 + .../notifications/handlers/workflowRun.ts | 74 + .../utils/notifications/notifications.ts | 13 +- src/renderer/utils/subject.test.ts | 3265 ++++++++--------- src/renderer/utils/subject.ts | 505 --- 22 files changed, 2979 insertions(+), 2691 deletions(-) create mode 100644 src/renderer/utils/notifications/handlers/checkSuite.ts create mode 100644 src/renderer/utils/notifications/handlers/commit.ts create mode 100644 src/renderer/utils/notifications/handlers/default.ts create mode 100644 src/renderer/utils/notifications/handlers/discussion.ts create mode 100644 src/renderer/utils/notifications/handlers/index.ts create mode 100644 src/renderer/utils/notifications/handlers/issue.ts create mode 100644 src/renderer/utils/notifications/handlers/pullRequest.ts create mode 100644 src/renderer/utils/notifications/handlers/release.ts create mode 100644 src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts create mode 100644 src/renderer/utils/notifications/handlers/repositoryInvitation.ts create mode 100644 src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.ts create mode 100644 src/renderer/utils/notifications/handlers/types.ts create mode 100644 src/renderer/utils/notifications/handlers/utils.ts create mode 100644 src/renderer/utils/notifications/handlers/workflowRun.ts delete mode 100644 src/renderer/utils/subject.ts diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index a1e12979c..9ff012f82 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -9,11 +9,9 @@ import type { Notification } from '../../typesGitHub'; import { cn } from '../../utils/cn'; import { isMarkAsDoneFeatureSupported } from '../../utils/features'; import { formatForDisplay } from '../../utils/helpers'; -import { - getNotificationTypeIcon, - getNotificationTypeIconColor, -} from '../../utils/icons'; +import { getNotificationTypeIconColor } from '../../utils/icons'; import { openNotification } from '../../utils/links'; +import { createNotificationHandler } from '../../utils/notifications/handlers'; import { HoverButton } from '../primitives/HoverButton'; import { HoverGroup } from '../primitives/HoverGroup'; import { NotificationFooter } from './NotificationFooter'; @@ -73,7 +71,9 @@ export const NotificationRow: FC = ({ unsubscribeNotification(notification); }; - const NotificationIcon = getNotificationTypeIcon(notification.subject); + const handler = createNotificationHandler(notification); + + const NotificationIcon = handler.getIcon(notification.subject); const iconColor = getNotificationTypeIconColor(notification.subject); const notificationType = formatForDisplay([ diff --git a/src/renderer/utils/__snapshots__/icons.test.ts.snap b/src/renderer/utils/__snapshots__/icons.test.ts.snap index 01486ff86..c5ea6da45 100644 --- a/src/renderer/utils/__snapshots__/icons.test.ts.snap +++ b/src/renderer/utils/__snapshots__/icons.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` 1`] = ` { diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index c207f3e2e..27e932ab5 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -10,11 +10,9 @@ import type { Notification } from '../typesGitHub'; import { getHtmlUrl, getLatestDiscussion } from './api/client'; import type { PlatformType } from './auth/types'; import { Constants } from './constants'; -import { - getCheckSuiteAttributes, - getClosestDiscussionCommentOrReply, - getWorkflowRunAttributes, -} from './subject'; +import { getCheckSuiteAttributes } from './notifications/handlers/checkSuite'; +import { getClosestDiscussionCommentOrReply } from './notifications/handlers/discussion'; +import { getWorkflowRunAttributes } from './notifications/handlers/workflowRun'; export function getPlatformFromHostname(hostname: string): PlatformType { return hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname) diff --git a/src/renderer/utils/icons.test.ts b/src/renderer/utils/icons.test.ts index 12a57331a..3ca9618b9 100644 --- a/src/renderer/utils/icons.test.ts +++ b/src/renderer/utils/icons.test.ts @@ -1,446 +1,446 @@ -import { - CheckIcon, - CommentIcon, - FeedPersonIcon, - FileDiffIcon, - MarkGithubIcon, - OrganizationIcon, -} from '@primer/octicons-react'; - -import { IconColor } from '../types'; -import type { - GitifyPullRequestReview, - StateType, - Subject, - SubjectType, -} from '../typesGitHub'; -import { - getAuthMethodIcon, - getDefaultUserIcon, - getNotificationTypeIcon, - getNotificationTypeIconColor, - getPlatformIcon, - getPullRequestReviewIcon, -} from './icons'; - -describe('renderer/utils/icons.ts', () => { - describe('getNotificationTypeIcon - should get the notification type icon', () => { - expect( - getNotificationTypeIcon( - createSubjectMock({ type: 'CheckSuite', state: null }), - ).displayName, - ).toBe('RocketIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'CheckSuite', - state: 'cancelled', - }), - ).displayName, - ).toBe('StopIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'CheckSuite', - state: 'failure', - }), - ).displayName, - ).toBe('XIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'CheckSuite', - state: 'skipped', - }), - ).displayName, - ).toBe('SkipIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'CheckSuite', - state: 'success', - }), - ).displayName, - ).toBe('CheckIcon'); - - expect( - getNotificationTypeIcon(createSubjectMock({ type: 'Commit' })) - .displayName, - ).toBe('GitCommitIcon'); - - expect( - getNotificationTypeIcon(createSubjectMock({ type: 'Discussion' })) - .displayName, - ).toBe('CommentDiscussionIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ type: 'Discussion', state: 'DUPLICATE' }), - ).displayName, - ).toBe('DiscussionDuplicateIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ type: 'Discussion', state: 'OUTDATED' }), - ).displayName, - ).toBe('DiscussionOutdatedIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ type: 'Discussion', state: 'RESOLVED' }), - ).displayName, - ).toBe('DiscussionClosedIcon'); - - expect( - getNotificationTypeIcon(createSubjectMock({ type: 'Issue' })).displayName, - ).toBe('IssueOpenedIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ type: 'Issue', state: 'draft' }), - ).displayName, - ).toBe('IssueDraftIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'Issue', - state: 'closed', - }), - ).displayName, - ).toBe('IssueClosedIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'Issue', - state: 'completed', - }), - ).displayName, - ).toBe('IssueClosedIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'Issue', - state: 'not_planned', - }), - ).displayName, - ).toBe('SkipIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'Issue', - state: 'reopened', - }), - ).displayName, - ).toBe('IssueReopenedIcon'); - - expect( - getNotificationTypeIcon(createSubjectMock({ type: 'PullRequest' })) - .displayName, - ).toBe('GitPullRequestIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'PullRequest', - state: 'draft', - }), - ).displayName, - ).toBe('GitPullRequestDraftIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'PullRequest', - state: 'closed', - }), - ).displayName, - ).toBe('GitPullRequestClosedIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'PullRequest', - state: 'merged', - }), - ).displayName, - ).toBe('GitMergeIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'Release', - }), - ).displayName, - ).toBe('TagIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'RepositoryDependabotAlertsThread', - }), - ).displayName, - ).toBe('AlertIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'RepositoryInvitation', - }), - ).displayName, - ).toBe('MailIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'RepositoryVulnerabilityAlert', - }), - ).displayName, - ).toBe('AlertIcon'); - - expect( - getNotificationTypeIcon( - createSubjectMock({ - type: 'WorkflowRun', - }), - ).displayName, - ).toBe('RocketIcon'); - - expect(getNotificationTypeIcon(createSubjectMock({})).displayName).toBe( - 'QuestionIcon', - ); - }); - - describe('getNotificationTypeIconColor', () => { - it('should format the notification color for check suite', () => { - expect( - getNotificationTypeIconColor( - createSubjectMock({ - type: 'CheckSuite', - state: 'cancelled', - }), - ), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ - type: 'CheckSuite', - state: 'failure', - }), - ), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ - type: 'CheckSuite', - state: 'skipped', - }), - ), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ - type: 'CheckSuite', - state: 'success', - }), - ), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ - type: 'CheckSuite', - state: null, - }), - ), - ).toMatchSnapshot(); - }); - - it('should format the notification color for state', () => { - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'ANSWERED' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'closed' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'completed' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'draft' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'merged' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ state: 'not_planned' }), - ), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'open' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'reopened' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor(createSubjectMock({ state: 'RESOLVED' })), - ).toMatchSnapshot(); - - expect( - getNotificationTypeIconColor( - createSubjectMock({ - state: 'something_else_unknown' as StateType, - }), - ), - ).toMatchSnapshot(); - }); - }); - - describe('getPullRequestReviewIcon', () => { - let mockReviewSingleReviewer: GitifyPullRequestReview; - let mockReviewMultipleReviewer: GitifyPullRequestReview; - - beforeEach(() => { - mockReviewSingleReviewer = { - state: 'APPROVED', - users: ['user1'], - }; - mockReviewMultipleReviewer = { - state: 'APPROVED', - users: ['user1', 'user2'], - }; - }); - - it('approved', () => { - mockReviewSingleReviewer.state = 'APPROVED'; - mockReviewMultipleReviewer.state = 'APPROVED'; - - expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ - type: CheckIcon, - color: IconColor.GREEN, - description: 'user1 approved these changes', - }); - - expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ - type: CheckIcon, - color: IconColor.GREEN, - description: 'user1, user2 approved these changes', - }); - }); - - it('changes requested', () => { - mockReviewSingleReviewer.state = 'CHANGES_REQUESTED'; - mockReviewMultipleReviewer.state = 'CHANGES_REQUESTED'; - - expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ - type: FileDiffIcon, - color: IconColor.RED, - description: 'user1 requested changes', - }); - - expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ - type: FileDiffIcon, - color: IconColor.RED, - description: 'user1, user2 requested changes', - }); - }); - - it('commented', () => { - mockReviewSingleReviewer.state = 'COMMENTED'; - mockReviewMultipleReviewer.state = 'COMMENTED'; - - expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ - type: CommentIcon, - color: IconColor.YELLOW, - description: 'user1 left review comments', - }); - - expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ - type: CommentIcon, - color: IconColor.YELLOW, - description: 'user1, user2 left review comments', - }); - }); - - it('dismissed', () => { - mockReviewSingleReviewer.state = 'DISMISSED'; - mockReviewMultipleReviewer.state = 'DISMISSED'; - - expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ - type: CommentIcon, - color: IconColor.GRAY, - description: 'user1 review has been dismissed', - }); - - expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ - type: CommentIcon, - color: IconColor.GRAY, - description: 'user1, user2 reviews have been dismissed', - }); - }); - - it('pending', () => { - mockReviewSingleReviewer.state = 'PENDING'; - mockReviewMultipleReviewer.state = 'PENDING'; - - expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toBeNull(); - - expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toBeNull(); - }); - }); - - describe('getAuthMethodIcon', () => { - expect(getAuthMethodIcon('GitHub App')).toMatchSnapshot(); - - expect(getAuthMethodIcon('OAuth App')).toMatchSnapshot(); - - expect(getAuthMethodIcon('Personal Access Token')).toMatchSnapshot(); - }); - - describe('getPlatformIcon', () => { - expect(getPlatformIcon('GitHub Cloud')).toMatchSnapshot(); - - expect(getPlatformIcon('GitHub Enterprise Server')).toMatchSnapshot(); - }); - - describe('getDefaultUserIcon', () => { - expect(getDefaultUserIcon('Bot')).toBe(MarkGithubIcon); - expect(getDefaultUserIcon('EnterpriseUserAccount')).toBe(FeedPersonIcon); - expect(getDefaultUserIcon('Mannequin')).toBe(MarkGithubIcon); - expect(getDefaultUserIcon('Organization')).toBe(OrganizationIcon); - expect(getDefaultUserIcon('User')).toBe(FeedPersonIcon); - }); -}); - -function createSubjectMock(mocks: { - title?: string; - type?: SubjectType; - state?: StateType; -}): Subject { - return { - title: mocks.title ?? 'Mock Subject', - type: mocks.type ?? ('Unknown' as SubjectType), - state: mocks.state ?? ('Unknown' as StateType), - url: null, - latest_comment_url: null, - }; -} +// import { +// CheckIcon, +// CommentIcon, +// FeedPersonIcon, +// FileDiffIcon, +// MarkGithubIcon, +// OrganizationIcon, +// } from '@primer/octicons-react'; + +// import { IconColor } from '../types'; +// import type { +// GitifyPullRequestReview, +// StateType, +// Subject, +// SubjectType, +// } from '../typesGitHub'; +// import { +// getAuthMethodIcon, +// getDefaultUserIcon, +// getNotificationTypeIcon, +// getNotificationTypeIconColor, +// getPlatformIcon, +// getPullRequestReviewIcon, +// } from './icons'; + +// describe('renderer/utils/icons.ts', () => { +// describe('getNotificationTypeIcon - should get the notification type icon', () => { +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ type: 'CheckSuite', state: null }), +// ).displayName, +// ).toBe('RocketIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'cancelled', +// }), +// ).displayName, +// ).toBe('StopIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'failure', +// }), +// ).displayName, +// ).toBe('XIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'skipped', +// }), +// ).displayName, +// ).toBe('SkipIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'success', +// }), +// ).displayName, +// ).toBe('CheckIcon'); + +// expect( +// getNotificationTypeIcon(createSubjectMock({ type: 'Commit' })) +// .displayName, +// ).toBe('GitCommitIcon'); + +// expect( +// getNotificationTypeIcon(createSubjectMock({ type: 'Discussion' })) +// .displayName, +// ).toBe('CommentDiscussionIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ type: 'Discussion', state: 'DUPLICATE' }), +// ).displayName, +// ).toBe('DiscussionDuplicateIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ type: 'Discussion', state: 'OUTDATED' }), +// ).displayName, +// ).toBe('DiscussionOutdatedIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ type: 'Discussion', state: 'RESOLVED' }), +// ).displayName, +// ).toBe('DiscussionClosedIcon'); + +// expect( +// getNotificationTypeIcon(createSubjectMock({ type: 'Issue' })).displayName, +// ).toBe('IssueOpenedIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ type: 'Issue', state: 'draft' }), +// ).displayName, +// ).toBe('IssueDraftIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'Issue', +// state: 'closed', +// }), +// ).displayName, +// ).toBe('IssueClosedIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'Issue', +// state: 'completed', +// }), +// ).displayName, +// ).toBe('IssueClosedIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'Issue', +// state: 'not_planned', +// }), +// ).displayName, +// ).toBe('SkipIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'Issue', +// state: 'reopened', +// }), +// ).displayName, +// ).toBe('IssueReopenedIcon'); + +// expect( +// getNotificationTypeIcon(createSubjectMock({ type: 'PullRequest' })) +// .displayName, +// ).toBe('GitPullRequestIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'PullRequest', +// state: 'draft', +// }), +// ).displayName, +// ).toBe('GitPullRequestDraftIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'PullRequest', +// state: 'closed', +// }), +// ).displayName, +// ).toBe('GitPullRequestClosedIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'PullRequest', +// state: 'merged', +// }), +// ).displayName, +// ).toBe('GitMergeIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'Release', +// }), +// ).displayName, +// ).toBe('TagIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'RepositoryDependabotAlertsThread', +// }), +// ).displayName, +// ).toBe('AlertIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'RepositoryInvitation', +// }), +// ).displayName, +// ).toBe('MailIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'RepositoryVulnerabilityAlert', +// }), +// ).displayName, +// ).toBe('AlertIcon'); + +// expect( +// getNotificationTypeIcon( +// createSubjectMock({ +// type: 'WorkflowRun', +// }), +// ).displayName, +// ).toBe('RocketIcon'); + +// expect(getNotificationTypeIcon(createSubjectMock({})).displayName).toBe( +// 'QuestionIcon', +// ); +// }); + +// describe('getNotificationTypeIconColor', () => { +// it('should format the notification color for check suite', () => { +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'cancelled', +// }), +// ), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'failure', +// }), +// ), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'skipped', +// }), +// ), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// type: 'CheckSuite', +// state: 'success', +// }), +// ), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// type: 'CheckSuite', +// state: null, +// }), +// ), +// ).toMatchSnapshot(); +// }); + +// it('should format the notification color for state', () => { +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'ANSWERED' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'closed' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'completed' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'draft' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'merged' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ state: 'not_planned' }), +// ), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'open' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'reopened' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor(createSubjectMock({ state: 'RESOLVED' })), +// ).toMatchSnapshot(); + +// expect( +// getNotificationTypeIconColor( +// createSubjectMock({ +// state: 'something_else_unknown' as StateType, +// }), +// ), +// ).toMatchSnapshot(); +// }); +// }); + +// describe('getPullRequestReviewIcon', () => { +// let mockReviewSingleReviewer: GitifyPullRequestReview; +// let mockReviewMultipleReviewer: GitifyPullRequestReview; + +// beforeEach(() => { +// mockReviewSingleReviewer = { +// state: 'APPROVED', +// users: ['user1'], +// }; +// mockReviewMultipleReviewer = { +// state: 'APPROVED', +// users: ['user1', 'user2'], +// }; +// }); + +// it('approved', () => { +// mockReviewSingleReviewer.state = 'APPROVED'; +// mockReviewMultipleReviewer.state = 'APPROVED'; + +// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ +// type: CheckIcon, +// color: IconColor.GREEN, +// description: 'user1 approved these changes', +// }); + +// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ +// type: CheckIcon, +// color: IconColor.GREEN, +// description: 'user1, user2 approved these changes', +// }); +// }); + +// it('changes requested', () => { +// mockReviewSingleReviewer.state = 'CHANGES_REQUESTED'; +// mockReviewMultipleReviewer.state = 'CHANGES_REQUESTED'; + +// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ +// type: FileDiffIcon, +// color: IconColor.RED, +// description: 'user1 requested changes', +// }); + +// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ +// type: FileDiffIcon, +// color: IconColor.RED, +// description: 'user1, user2 requested changes', +// }); +// }); + +// it('commented', () => { +// mockReviewSingleReviewer.state = 'COMMENTED'; +// mockReviewMultipleReviewer.state = 'COMMENTED'; + +// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ +// type: CommentIcon, +// color: IconColor.YELLOW, +// description: 'user1 left review comments', +// }); + +// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ +// type: CommentIcon, +// color: IconColor.YELLOW, +// description: 'user1, user2 left review comments', +// }); +// }); + +// it('dismissed', () => { +// mockReviewSingleReviewer.state = 'DISMISSED'; +// mockReviewMultipleReviewer.state = 'DISMISSED'; + +// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ +// type: CommentIcon, +// color: IconColor.GRAY, +// description: 'user1 review has been dismissed', +// }); + +// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ +// type: CommentIcon, +// color: IconColor.GRAY, +// description: 'user1, user2 reviews have been dismissed', +// }); +// }); + +// it('pending', () => { +// mockReviewSingleReviewer.state = 'PENDING'; +// mockReviewMultipleReviewer.state = 'PENDING'; + +// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toBeNull(); + +// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toBeNull(); +// }); +// }); + +// describe('getAuthMethodIcon', () => { +// expect(getAuthMethodIcon('GitHub App')).toMatchSnapshot(); + +// expect(getAuthMethodIcon('OAuth App')).toMatchSnapshot(); + +// expect(getAuthMethodIcon('Personal Access Token')).toMatchSnapshot(); +// }); + +// describe('getPlatformIcon', () => { +// expect(getPlatformIcon('GitHub Cloud')).toMatchSnapshot(); + +// expect(getPlatformIcon('GitHub Enterprise Server')).toMatchSnapshot(); +// }); + +// describe('getDefaultUserIcon', () => { +// expect(getDefaultUserIcon('Bot')).toBe(MarkGithubIcon); +// expect(getDefaultUserIcon('EnterpriseUserAccount')).toBe(FeedPersonIcon); +// expect(getDefaultUserIcon('Mannequin')).toBe(MarkGithubIcon); +// expect(getDefaultUserIcon('Organization')).toBe(OrganizationIcon); +// expect(getDefaultUserIcon('User')).toBe(FeedPersonIcon); +// }); +// }); + +// function createSubjectMock(mocks: { +// title?: string; +// type?: SubjectType; +// state?: StateType; +// }): Subject { +// return { +// title: mocks.title ?? 'Mock Subject', +// type: mocks.type ?? ('Unknown' as SubjectType), +// state: mocks.state ?? ('Unknown' as StateType), +// url: null, +// latest_comment_url: null, +// }; +// } diff --git a/src/renderer/utils/icons.ts b/src/renderer/utils/icons.ts index 073a5faae..c9048aca8 100644 --- a/src/renderer/utils/icons.ts +++ b/src/renderer/utils/icons.ts @@ -1,38 +1,17 @@ import type { FC } from 'react'; import { - AlertIcon, AppsIcon, CheckIcon, - CommentDiscussionIcon, CommentIcon, - DiscussionClosedIcon, - DiscussionDuplicateIcon, - DiscussionOutdatedIcon, FeedPersonIcon, FileDiffIcon, - GitCommitIcon, - GitMergeIcon, - GitPullRequestClosedIcon, - GitPullRequestDraftIcon, - GitPullRequestIcon, - IssueClosedIcon, - IssueDraftIcon, - IssueOpenedIcon, - IssueReopenedIcon, KeyIcon, - MailIcon, MarkGithubIcon, type OcticonProps, OrganizationIcon, PersonIcon, - QuestionIcon, - RocketIcon, ServerIcon, - SkipIcon, - StopIcon, - TagIcon, - XIcon, } from '@primer/octicons-react'; import { IconColor, type PullRequestApprovalIcon } from '../types'; @@ -43,74 +22,6 @@ import type { } from '../typesGitHub'; import type { AuthMethod, PlatformType } from './auth/types'; -export function getNotificationTypeIcon(subject: Subject): FC { - switch (subject.type) { - case 'CheckSuite': - switch (subject.state) { - case 'cancelled': - return StopIcon; - case 'failure': - return XIcon; - case 'skipped': - return SkipIcon; - case 'success': - return CheckIcon; - default: - return RocketIcon; - } - case 'Commit': - return GitCommitIcon; - case 'Discussion': - switch (subject.state) { - case 'DUPLICATE': - return DiscussionDuplicateIcon; - case 'OUTDATED': - return DiscussionOutdatedIcon; - case 'RESOLVED': - return DiscussionClosedIcon; - default: - return CommentDiscussionIcon; - } - case 'Issue': - switch (subject.state) { - case 'draft': - return IssueDraftIcon; - case 'closed': - case 'completed': - return IssueClosedIcon; - case 'not_planned': - return SkipIcon; - case 'reopened': - return IssueReopenedIcon; - default: - return IssueOpenedIcon; - } - case 'PullRequest': - switch (subject.state) { - case 'draft': - return GitPullRequestDraftIcon; - case 'closed': - return GitPullRequestClosedIcon; - case 'merged': - return GitMergeIcon; - default: - return GitPullRequestIcon; - } - case 'Release': - return TagIcon; - case 'RepositoryDependabotAlertsThread': - return AlertIcon; - case 'RepositoryInvitation': - return MailIcon; - case 'RepositoryVulnerabilityAlert': - return AlertIcon; - case 'WorkflowRun': - return RocketIcon; - default: - return QuestionIcon; - } -} - export function getNotificationTypeIconColor(subject: Subject): IconColor { switch (subject.state) { case 'open': diff --git a/src/renderer/utils/notifications/handlers/checkSuite.ts b/src/renderer/utils/notifications/handlers/checkSuite.ts new file mode 100644 index 000000000..da3c7809e --- /dev/null +++ b/src/renderer/utils/notifications/handlers/checkSuite.ts @@ -0,0 +1,102 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { + CheckIcon, + RocketIcon, + SkipIcon, + StopIcon, + XIcon, +} from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + CheckSuiteAttributes, + CheckSuiteStatus, + GitifySubject, + Notification, + Subject, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class CheckSuiteHandler implements NotificationTypeHandler { + readonly type = 'CheckSuite'; + + async enrich( + notification: Notification, + _settings: SettingsState, + ): Promise { + const state = getCheckSuiteAttributes(notification)?.status; + + if (state) { + return { + state: state, + user: null, + }; + } + + return null; + } + + getIcon(subject: Subject): FC | null { + switch (subject.state) { + case 'cancelled': + return StopIcon; + case 'failure': + return XIcon; + case 'skipped': + return SkipIcon; + case 'success': + return CheckIcon; + default: + return RocketIcon; + } + } +} + +export const checkSuiteHandler = new CheckSuiteHandler(); + +/** + * Ideally we would be using a GitHub API to fetch the CheckSuite / WorkflowRun state, + * but there isn't an obvious/clean way to do this currently. + */ +export function getCheckSuiteAttributes( + notification: Notification, +): CheckSuiteAttributes | null { + const regexPattern = + /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; + + const matches = regexPattern.exec(notification.subject.title); + + if (matches) { + const { groups } = matches; + + return { + workflowName: groups.workflowName, + attemptNumber: groups.attemptNumber + ? Number.parseInt(groups.attemptNumber) + : null, + status: getCheckSuiteStatus(groups.statusDisplayName), + statusDisplayName: groups.statusDisplayName, + branchName: groups.branchName, + }; + } + + return null; +} + +function getCheckSuiteStatus(statusDisplayName: string): CheckSuiteStatus { + switch (statusDisplayName) { + case 'cancelled': + return 'cancelled'; + case 'failed': + case 'failed at startup': + return 'failure'; + case 'skipped': + return 'skipped'; + case 'succeeded': + return 'success'; + default: + return null; + } +} diff --git a/src/renderer/utils/notifications/handlers/commit.ts b/src/renderer/utils/notifications/handlers/commit.ts new file mode 100644 index 000000000..2cdf697cc --- /dev/null +++ b/src/renderer/utils/notifications/handlers/commit.ts @@ -0,0 +1,54 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { GitCommitIcon } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, + User, +} from '../../../typesGitHub'; +import { getCommit, getCommitComment } from '../../api/client'; +import type { NotificationTypeHandler } from './types'; +import { getSubjectUser } from './utils'; + +class CommitHandler implements NotificationTypeHandler { + readonly type = 'Commit'; + + async enrich( + notification: Notification, + _settings: SettingsState, + ): Promise { + let user: User; + + if (notification.subject.latest_comment_url) { + const commitComment = ( + await getCommitComment( + notification.subject.latest_comment_url, + notification.account.token, + ) + ).data; + + user = commitComment.user; + } else { + const commit = ( + await getCommit(notification.subject.url, notification.account.token) + ).data; + + user = commit.author; + } + + return { + state: null, + user: getSubjectUser([user]), + }; + } + + getIcon(_subject: Subject): FC | null { + return GitCommitIcon; + } +} + +export const commitHandler = new CommitHandler(); diff --git a/src/renderer/utils/notifications/handlers/default.ts b/src/renderer/utils/notifications/handlers/default.ts new file mode 100644 index 000000000..0b15c8a2e --- /dev/null +++ b/src/renderer/utils/notifications/handlers/default.ts @@ -0,0 +1,27 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { QuestionIcon } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class DefaultHandler implements NotificationTypeHandler { + async enrich( + _notification: Notification, + _settings: SettingsState, + ): Promise { + return; + } + + getIcon(_subject: Subject): FC | null { + return QuestionIcon; + } +} + +export const defaultHandler = new DefaultHandler(); diff --git a/src/renderer/utils/notifications/handlers/discussion.ts b/src/renderer/utils/notifications/handlers/discussion.ts new file mode 100644 index 000000000..1e185fd71 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/discussion.ts @@ -0,0 +1,128 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { + CommentDiscussionIcon, + DiscussionClosedIcon, + DiscussionDuplicateIcon, + DiscussionOutdatedIcon, +} from '@primer/octicons-react'; + +import { differenceInMilliseconds } from 'date-fns'; + +import type { SettingsState } from '../../../types'; +import type { + DiscussionComment, + DiscussionStateType, + GitifySubject, + Notification, + Subject, + SubjectUser, +} from '../../../typesGitHub'; +import { getLatestDiscussion } from '../../api/client'; +import { isStateFilteredOut } from '../filters/filter'; +import type { NotificationTypeHandler } from './types'; + +class DiscussionHandler implements NotificationTypeHandler { + readonly type = 'Discussion'; + + async enrich( + notification: Notification, + settings: SettingsState, + ): Promise { + const discussion = await getLatestDiscussion(notification); + let discussionState: DiscussionStateType = 'OPEN'; + + if (discussion) { + if (discussion.isAnswered) { + discussionState = 'ANSWERED'; + } + + if (discussion.stateReason) { + discussionState = discussion.stateReason; + } + } + + // Return early if this notification would be hidden by filters + if (isStateFilteredOut(discussionState, settings)) { + return null; + } + + // Return early if this notification would be hidden by filters + if (isStateFilteredOut(discussionState, settings)) { + return null; + } + + const latestDiscussionComment = getClosestDiscussionCommentOrReply( + notification, + discussion.comments.nodes, + ); + + let discussionUser: SubjectUser = { + login: discussion.author.login, + html_url: discussion.author.url, + avatar_url: discussion.author.avatar_url, + type: discussion.author.type, + }; + if (latestDiscussionComment) { + discussionUser = { + login: latestDiscussionComment.author.login, + html_url: latestDiscussionComment.author.url, + avatar_url: latestDiscussionComment.author.avatar_url, + type: latestDiscussionComment.author.type, + }; + } + + return { + number: discussion.number, + state: discussionState, + user: discussionUser, + comments: discussion.comments.totalCount, + labels: discussion.labels?.nodes.map((label) => label.name) ?? [], + }; + } + + getIcon(subject: Subject): FC | null { + switch (subject.state) { + case 'DUPLICATE': + return DiscussionDuplicateIcon; + case 'OUTDATED': + return DiscussionOutdatedIcon; + case 'RESOLVED': + return DiscussionClosedIcon; + default: + return CommentDiscussionIcon; + } + } +} + +export const discussionHandler = new DiscussionHandler(); + +export function getClosestDiscussionCommentOrReply( + notification: Notification, + comments: DiscussionComment[], +): DiscussionComment | null { + if (!comments || comments.length === 0) { + return null; + } + + const targetTimestamp = notification.updated_at; + + const allCommentsAndReplies = comments.flatMap((comment) => [ + comment, + ...comment.replies.nodes, + ]); + + // Find the closest match using the target timestamp + const closestComment = allCommentsAndReplies.reduce((prev, curr) => { + const prevDiff = Math.abs( + differenceInMilliseconds(prev.createdAt, targetTimestamp), + ); + const currDiff = Math.abs( + differenceInMilliseconds(curr.createdAt, targetTimestamp), + ); + return currDiff < prevDiff ? curr : prev; + }, allCommentsAndReplies[0]); + + return closestComment; +} diff --git a/src/renderer/utils/notifications/handlers/index.ts b/src/renderer/utils/notifications/handlers/index.ts new file mode 100644 index 000000000..a4f02d1de --- /dev/null +++ b/src/renderer/utils/notifications/handlers/index.ts @@ -0,0 +1,52 @@ +import type { Notification } from '../../../typesGitHub'; +import { checkSuiteHandler } from './checkSuite'; +import { commitHandler } from './commit'; +import { defaultHandler } from './default'; +import { discussionHandler } from './discussion'; +import { issueHandler } from './issue'; +import { pullRequestHandler } from './pullRequest'; +import { releaseHandler } from './release'; +import { repositoryDependabotAlertsThreadHandler } from './repositoryDependabotAlertsThread copy'; +import { repositoryInvitationHandler } from './repositoryInvitation'; +import { repositoryVulnerabilityAlertHandler } from './repositoryVulnerabilityAlert'; +import type { NotificationTypeHandler } from './types'; +import { workflowRunHandler } from './workflowRun'; + +export function createNotificationHandler( + notification: Notification, +): NotificationTypeHandler | null { + switch (notification.subject.type) { + case 'CheckSuite': + return checkSuiteHandler; + case 'WorkflowRun': + return workflowRunHandler; + case 'Commit': + return commitHandler; + case 'Discussion': + return discussionHandler; + case 'Issue': + return issueHandler; + case 'PullRequest': + return pullRequestHandler; + case 'Release': + return releaseHandler; + case 'RepositoryDependabotAlertsThread': + return repositoryDependabotAlertsThreadHandler; + case 'RepositoryInvitation': + return repositoryInvitationHandler; + case 'RepositoryVulnerabilityAlert': + return repositoryVulnerabilityAlertHandler; + default: + return defaultHandler; + } +} + +export const handlers = { + checkSuiteHandler, + workflowRunHandler, + commitHandler, + discussionHandler, + issueHandler, + pullRequestHandler, + releaseHandler, +}; diff --git a/src/renderer/utils/notifications/handlers/issue.ts b/src/renderer/utils/notifications/handlers/issue.ts new file mode 100644 index 000000000..46f1dbffd --- /dev/null +++ b/src/renderer/utils/notifications/handlers/issue.ts @@ -0,0 +1,81 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { + IssueClosedIcon, + IssueDraftIcon, + IssueOpenedIcon, + IssueReopenedIcon, + SkipIcon, +} from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, + User, +} from '../../../typesGitHub'; +import { getIssue, getIssueOrPullRequestComment } from '../../api/client'; +import { isStateFilteredOut } from '../filters/filter'; +import type { NotificationTypeHandler } from './types'; +import { getSubjectUser } from './utils'; + +class IssueHandler implements NotificationTypeHandler { + readonly type = 'Issue'; + + async enrich( + notification: Notification, + settings: SettingsState, + ): Promise { + const issue = ( + await getIssue(notification.subject.url, notification.account.token) + ).data; + + const issueState = issue.state_reason ?? issue.state; + + // Return early if this notification would be hidden by filters + if (isStateFilteredOut(issueState, settings)) { + return null; + } + + let issueCommentUser: User; + + if (notification.subject.latest_comment_url) { + const issueComment = ( + await getIssueOrPullRequestComment( + notification.subject.latest_comment_url, + notification.account.token, + ) + ).data; + issueCommentUser = issueComment.user; + } + + return { + number: issue.number, + state: issue.state_reason ?? issue.state, + user: getSubjectUser([issueCommentUser, issue.user]), + comments: issue.comments, + labels: issue.labels?.map((label) => label.name) ?? [], + milestone: issue.milestone, + }; + } + + getIcon(subject: Subject): FC | null { + switch (subject.state) { + case 'draft': + return IssueDraftIcon; + case 'closed': + case 'completed': + return IssueClosedIcon; + case 'not_planned': + return SkipIcon; + case 'reopened': + return IssueReopenedIcon; + default: + return IssueOpenedIcon; + } + } +} + +export const issueHandler = new IssueHandler(); diff --git a/src/renderer/utils/notifications/handlers/pullRequest.ts b/src/renderer/utils/notifications/handlers/pullRequest.ts new file mode 100644 index 000000000..d6787a785 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/pullRequest.ts @@ -0,0 +1,175 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, +} from '@primer/octicons-react'; + +import type { Link, SettingsState } from '../../../types'; +import type { + GitifyPullRequestReview, + GitifySubject, + Notification, + PullRequest, + PullRequestReview, + PullRequestStateType, + Subject, + User, +} from '../../../typesGitHub'; +import { + getIssueOrPullRequestComment, + getPullRequest, + getPullRequestReviews, +} from '../../api/client'; +import { isStateFilteredOut, isUserFilteredOut } from '../filters/filter'; +import type { NotificationTypeHandler } from './types'; +import { getSubjectUser } from './utils'; + +class PullRequestHandler implements NotificationTypeHandler { + readonly type = 'PullRequest' as const; + + async enrich( + notification: Notification, + settings: SettingsState, + ): Promise { + const pr = ( + await getPullRequest(notification.subject.url, notification.account.token) + ).data; + + let prState: PullRequestStateType = pr.state; + if (pr.merged) { + prState = 'merged'; + } else if (pr.draft) { + prState = 'draft'; + } + + // Return early if this notification would be hidden by state filters + if (isStateFilteredOut(prState, settings)) { + return null; + } + + let prCommentUser: User; + if ( + notification.subject.latest_comment_url && + notification.subject.latest_comment_url !== notification.subject.url + ) { + const prComment = ( + await getIssueOrPullRequestComment( + notification.subject.latest_comment_url, + notification.account.token, + ) + ).data; + prCommentUser = prComment.user; + } + + const prUser = getSubjectUser([prCommentUser, pr.user]); + + // Return early if this notification would be hidden by user filters + if (isUserFilteredOut(prUser, settings)) { + return null; + } + + const reviews = await getLatestReviewForReviewers(notification); + const linkedIssues = parseLinkedIssuesFromPr(pr); + + return { + number: pr.number, + state: prState, + user: prUser, + reviews: reviews, + comments: pr.comments, + labels: pr.labels?.map((label) => label.name) ?? [], + linkedIssues: linkedIssues, + milestone: pr.milestone, + }; + } + + getIcon(subject: Subject): FC | null { + switch (subject.state) { + case 'draft': + return GitPullRequestDraftIcon; + case 'closed': + return GitPullRequestClosedIcon; + case 'merged': + return GitMergeIcon; + default: + return GitPullRequestIcon; + } + } +} + +export const pullRequestHandler = new PullRequestHandler(); + +export async function getLatestReviewForReviewers( + notification: Notification, +): Promise | null { + if (notification.subject.type !== 'PullRequest') { + return null; + } + + const prReviews = await getPullRequestReviews( + `${notification.subject.url}/reviews` as Link, + notification.account.token, + ); + + if (!prReviews.data.length) { + return null; + } + + // Find the most recent review for each reviewer + const latestReviews: PullRequestReview[] = []; + const sortedReviews = prReviews.data.reverse(); + for (const prReview of sortedReviews) { + const reviewerFound = latestReviews.find( + (review) => review.user.login === prReview.user.login, + ); + + if (!reviewerFound) { + latestReviews.push(prReview); + } + } + + // Group by the review state + const reviewers: GitifyPullRequestReview[] = []; + for (const prReview of latestReviews) { + const reviewerFound = reviewers.find( + (review) => review.state === prReview.state, + ); + + if (!reviewerFound) { + reviewers.push({ + state: prReview.state, + users: [prReview.user.login], + }); + } else { + reviewerFound.users.push(prReview.user.login); + } + } + + // Sort reviews by state for consistent order when rendering + return reviewers.sort((a, b) => { + return a.state.localeCompare(b.state); + }); +} + +export function parseLinkedIssuesFromPr(pr: PullRequest): string[] { + const linkedIssues: string[] = []; + + if (!pr.body || pr.user.type !== 'User') { + return linkedIssues; + } + + const regexPattern = /\s?#(\d+)\s?/gi; + const matches = pr.body.matchAll(regexPattern); + + for (const match of matches) { + if (match[0]) { + linkedIssues.push(match[0].trim()); + } + } + + return linkedIssues; +} diff --git a/src/renderer/utils/notifications/handlers/release.ts b/src/renderer/utils/notifications/handlers/release.ts new file mode 100644 index 000000000..6400ef362 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/release.ts @@ -0,0 +1,47 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { TagIcon } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + StateType, + Subject, +} from '../../../typesGitHub'; +import { getRelease } from '../../api/client'; +import { isStateFilteredOut } from '../filters/filter'; +import type { NotificationTypeHandler } from './types'; +import { getSubjectUser } from './utils'; + +class ReleaseHandler implements NotificationTypeHandler { + readonly type = 'Release'; + + async enrich( + notification: Notification, + settings: SettingsState, + ): Promise { + const releaseState: StateType = null; // Release notifications are stateless + + // Return early if this notification would be hidden by filters + if (isStateFilteredOut(releaseState, settings)) { + return null; + } + + const release = ( + await getRelease(notification.subject.url, notification.account.token) + ).data; + + return { + state: releaseState, + user: getSubjectUser([release.author]), + }; + } + + getIcon(_subject: Subject): FC | null { + return TagIcon; + } +} + +export const releaseHandler = new ReleaseHandler(); diff --git a/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts b/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts new file mode 100644 index 000000000..6fd5a49f5 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { AlertIcon } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class RepositoryDependabotAlertsThreadHandler + implements NotificationTypeHandler +{ + readonly type = 'RepositoryDependabotAlertsThread'; + + async enrich( + _notification: Notification, + _settings: SettingsState, + ): Promise { + return; + } + + getIcon(_subject: Subject): FC | null { + return AlertIcon; + } +} + +export const repositoryDependabotAlertsThreadHandler = + new RepositoryDependabotAlertsThreadHandler(); diff --git a/src/renderer/utils/notifications/handlers/repositoryInvitation.ts b/src/renderer/utils/notifications/handlers/repositoryInvitation.ts new file mode 100644 index 000000000..6eac000fc --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryInvitation.ts @@ -0,0 +1,28 @@ +import type { FC } from 'react'; + +import { MailIcon, type OcticonProps } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class RepositoryInvitationHandler implements NotificationTypeHandler { + readonly type = 'RepositoryInvitation'; + + async enrich( + _notification: Notification, + _settings: SettingsState, + ): Promise { + return; + } + + getIcon(_subject: Subject): FC | null { + return MailIcon; + } +} + +export const repositoryInvitationHandler = new RepositoryInvitationHandler(); diff --git a/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.ts b/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.ts new file mode 100644 index 000000000..5647d6ebe --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.ts @@ -0,0 +1,29 @@ +import type { FC } from 'react'; + +import { AlertIcon, type OcticonProps } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class RepositoryVulnerabilityAlertHandler implements NotificationTypeHandler { + readonly type = 'RepositoryVulnerabilityAlert'; + + async enrich( + _notification: Notification, + _settings: SettingsState, + ): Promise { + return; + } + + getIcon(_subject: Subject): FC | null { + return AlertIcon; + } +} + +export const repositoryVulnerabilityAlertHandler = + new RepositoryVulnerabilityAlertHandler(); diff --git a/src/renderer/utils/notifications/handlers/types.ts b/src/renderer/utils/notifications/handlers/types.ts new file mode 100644 index 000000000..2c1bf3536 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/types.ts @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; + +import type { Link, SettingsState } from '../../../types'; +import type { + GitifySubject, + Notification, + Subject, + SubjectType, +} from '../../../typesGitHub'; + +export interface NotificationTypeHandler { + readonly type?: SubjectType; + + /** + * Enrich a notification. Settings may be unused for some handlers. + */ + enrich( + notification: Notification, + settings: SettingsState, + ): Promise; + + /** Return an icon component for this notification type. */ + getIcon(subject: Subject): FC | null; + + /** Build a URL for this notification type (subject urls / comments resolved separately upstream). */ + buildUrl?(notification: Notification): Promise | Link; + + /** Optional color helper if we later move color logic into handlers. */ + getIconColor?(subject: Subject): string | undefined; +} diff --git a/src/renderer/utils/notifications/handlers/utils.ts b/src/renderer/utils/notifications/handlers/utils.ts new file mode 100644 index 000000000..5bee31fe5 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/utils.ts @@ -0,0 +1,25 @@ +import type { SubjectUser, User } from '../../../typesGitHub'; + +/** + * Construct the notification subject user based on an order prioritized list of users + * @param users array of users in order or priority + * @returns the subject user + */ +export function getSubjectUser(users: User[]): SubjectUser { + let subjectUser: SubjectUser = null; + + for (const user of users) { + if (user) { + subjectUser = { + login: user.login, + html_url: user.html_url, + avatar_url: user.avatar_url, + type: user.type, + }; + + return subjectUser; + } + } + + return subjectUser; +} diff --git a/src/renderer/utils/notifications/handlers/workflowRun.ts b/src/renderer/utils/notifications/handlers/workflowRun.ts new file mode 100644 index 000000000..9bd35f739 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/workflowRun.ts @@ -0,0 +1,74 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; +import { RocketIcon } from '@primer/octicons-react'; + +import type { SettingsState } from '../../../types'; +import type { + CheckSuiteStatus, + GitifySubject, + Notification, + Subject, + WorkflowRunAttributes, +} from '../../../typesGitHub'; +import type { NotificationTypeHandler } from './types'; + +class WorkflowRunHandler implements NotificationTypeHandler { + readonly type = 'WorkflowRun'; + + async enrich( + notification: Notification, + _settings: SettingsState, + ): Promise { + const state = getWorkflowRunAttributes(notification)?.status; + + if (state) { + return { + state: state, + user: null, + }; + } + + return null; + } + + getIcon(_subject: Subject): FC | null { + return RocketIcon; + } +} + +export const workflowRunHandler = new WorkflowRunHandler(); + +/** + * Ideally we would be using a GitHub API to fetch the CheckSuite / WorkflowRun state, + * but there isn't an obvious/clean way to do this currently. + */ +export function getWorkflowRunAttributes( + notification: Notification, +): WorkflowRunAttributes | null { + const regexPattern = + /^(?.*?) requested your (?.*?) to deploy to an environment$/; + + const matches = regexPattern.exec(notification.subject.title); + + if (matches) { + const { groups } = matches; + + return { + user: groups.user, + status: getWorkflowRunStatus(groups.statusDisplayName), + statusDisplayName: groups.statusDisplayName, + }; + } + + return null; +} + +function getWorkflowRunStatus(statusDisplayName: string): CheckSuiteStatus { + switch (statusDisplayName) { + case 'review': + return 'waiting'; + default: + return null; + } +} diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index 8c0473234..0ab99bad9 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -8,11 +8,11 @@ import type { GitifySubject, Notification } from '../../typesGitHub'; import { listNotificationsForAuthenticatedUser } from '../api/client'; import { determineFailureType } from '../api/errors'; import { updateTrayIcon } from '../comms'; -import { getGitifySubjectDetails } from '../subject'; import { filterBaseNotifications, filterDetailedNotifications, } from './filters/filter'; +import { createNotificationHandler } from './handlers'; export function setTrayIconColor(notifications: AccountNotifications[]) { const allNotificationsCount = getNotificationCount(notifications); @@ -108,10 +108,13 @@ export async function enrichNotifications( let additionalSubjectDetails: GitifySubject = {}; try { - additionalSubjectDetails = await getGitifySubjectDetails( - notification, - settings, - ); + const handler = createNotificationHandler(notification); + if (handler) { + additionalSubjectDetails = await handler.enrich( + notification, + settings, + ); + } } catch (err) { logError( 'enrichNotifications', diff --git a/src/renderer/utils/subject.test.ts b/src/renderer/utils/subject.test.ts index ce0d7ff8e..21c0b7b14 100644 --- a/src/renderer/utils/subject.test.ts +++ b/src/renderer/utils/subject.test.ts @@ -1,1635 +1,1630 @@ -import axios from 'axios'; -import nock from 'nock'; - -import { - partialMockNotification, - partialMockUser, -} from '../__mocks__/partial-mocks'; -import type { Link } from '../types'; -import type { - Discussion, - DiscussionAuthor, - DiscussionStateType, - Notification, - PullRequest, - Repository, -} from '../typesGitHub'; -import { - getCheckSuiteAttributes, - getGitifySubjectDetails, - getLatestReviewForReviewers, - getSubjectUser, - getWorkflowRunAttributes, - parseLinkedIssuesFromPr, -} from './subject'; - -const mockAuthor = partialMockUser('some-author'); -const mockCommenter = partialMockUser('some-commenter'); -const mockDiscussionAuthor: DiscussionAuthor = { - login: 'discussion-author', - url: 'https://github.com/discussion-author' as Link, - avatar_url: 'https://avatars.githubusercontent.com/u/123456789?v=4' as Link, - type: 'User', -}; - -import * as logger from '../../shared/logger'; -import { mockSettings } from '../__mocks__/state-mocks'; - -describe('renderer/utils/subject.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('getGitifySubjectDetails', () => { - describe('CheckSuites - GitHub Actions', () => { - it('cancelled check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run cancelled for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'cancelled', - user: null, - }); - }); - - it('failed check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run failed for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'failure', - user: null, - }); - }); - - it('failed at startup check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run failed at startup for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'failure', - user: null, - }); - }); - - it('multiple attempts failed check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run, Attempt #3 failed for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'failure', - user: null, - }); - }); - - it('skipped check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run skipped for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'skipped', - user: null, - }); - }); - - it('successful check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run succeeded for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'success', - user: null, - }); - }); - - it('unknown check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run unknown-status for main branch', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toBeNull(); - }); - - it('unhandled check suite title', async () => { - const mockNotification = partialMockNotification({ - title: 'A title that is not in the structure we expect', - type: 'CheckSuite', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toBeNull(); - }); - }); - describe('Commits', () => { - it('get commit commenter', async () => { - const mockNotification = partialMockNotification({ - title: 'This is a commit with comments', - type: 'Commit', - url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/comments/141012658' as Link, - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', - ) - .reply(200, { author: mockAuthor }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/comments/141012658') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: null, - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - }); - }); - - it('get commit without commenter', async () => { - const mockNotification = partialMockNotification({ - title: 'This is a commit with comments', - type: 'Commit', - url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, - latest_comment_url: null, - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', - ) - .reply(200, { author: mockAuthor }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: null, - user: { - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }, - }); - }); - - it('return early if commit state filtered', async () => { - const mockNotification = partialMockNotification({ - title: 'This is a commit with comments', - type: 'Commit', - url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, - latest_comment_url: null, - }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); - - expect(result).toEqual(null); - }); - }); - - describe('Discussions', () => { - const partialRepository: Partial = { - full_name: 'gitify-app/notifications-test', - }; - - const mockNotification = partialMockNotification({ - title: 'This is a mock discussion', - type: 'Discussion', - }); - mockNotification.updated_at = '2024-01-01T00:00:00Z'; - mockNotification.repository = { - ...(partialRepository as Repository), - }; - - it('answered discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode(null, true)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'ANSWERED', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('duplicate discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode('DUPLICATE', false)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'DUPLICATE', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('open discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode(null, false)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'OPEN', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('outdated discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode('OUTDATED', false)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'OUTDATED', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('reopened discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode('REOPENED', false)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'REOPENED', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('resolved discussion state', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode('RESOLVED', true)], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'RESOLVED', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: [], - }); - }); - - it('discussion with labels', async () => { - const mockDiscussion = mockDiscussionNode(null, true); - mockDiscussion.labels = { - nodes: [ - { - name: 'enhancement', - }, - ], - }; - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussion], - }, - }, - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'ANSWERED', - user: { - login: mockDiscussionAuthor.login, - html_url: mockDiscussionAuthor.url, - avatar_url: mockDiscussionAuthor.avatar_url, - type: mockDiscussionAuthor.type, - }, - comments: 0, - labels: ['enhancement'], - }); - }); - - it('early return if discussion state filtered', async () => { - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [mockDiscussionNode(null, false)], - }, - }, - }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); - - expect(result).toEqual(null); - }); - }); - - describe('Issues', () => { - let mockNotification: Notification; - - beforeEach(() => { - mockNotification = partialMockNotification({ - title: 'This is a mock issue', - type: 'Issue', - url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, - }); - }); - - it('open issue state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - - it('closed issue state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'closed', - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'closed', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - - it('completed issue state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'closed', - state_reason: 'completed', - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'completed', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - - it('not_planned issue state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - state_reason: 'not_planned', - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'not_planned', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - - it('reopened issue state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - state_reason: 'reopened', - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'reopened', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - - it('handle issues without latest_comment_url', async () => { - mockNotification.subject.latest_comment_url = null; - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }, - labels: [], - }); - }); - - describe('Issue With Labels', () => { - it('with labels', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - user: mockAuthor, - labels: [{ name: 'enhancement' }], - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/issues/comments/302888448', - ) - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: ['enhancement'], - }); - }); - - it('handle null labels', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - user: mockAuthor, - labels: null, - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/issues/comments/302888448', - ) - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - labels: [], - }); - }); - }); - - it('early return if issue state filtered out', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .reply(200, { - number: 123, - state: 'open', - user: mockAuthor, - labels: [], - }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); - - expect(result).toEqual(null); - }); - }); - - describe('Pull Requests', () => { - let mockNotification: Notification; - - beforeEach(() => { - mockNotification = partialMockNotification({ - title: 'This is a mock pull request', - type: 'PullRequest', - url: 'https://api.github.com/repos/gitify-app/notifications-test/pulls/1' as Link, - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, - }); - }); - - it('closed pull request state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'closed', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'closed', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - it('draft pull request state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: true, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'draft', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - it('merged pull request state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: true, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'merged', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - it('open pull request state', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - it('avoid fetching comments if latest_comment_url and url are the same', async () => { - mockNotification.subject.latest_comment_url = - mockNotification.subject.url; - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - it('handle pull request without latest_comment_url', async () => { - mockNotification.subject.latest_comment_url = null; - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - - describe('Pull Request Reviews - Latest Reviews By Reviewer', () => { - it('returns latest review state per reviewer', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, [ - { - user: { - login: 'reviewer-1', - }, - state: 'REQUESTED_CHANGES', - }, - { - user: { - login: 'reviewer-2', - }, - state: 'COMMENTED', - }, - { - user: { - login: 'reviewer-1', - }, - state: 'APPROVED', - }, - { - user: { - login: 'reviewer-3', - }, - state: 'APPROVED', - }, - ]); - - const result = await getLatestReviewForReviewers(mockNotification); - - expect(result).toEqual([ - { state: 'APPROVED', users: ['reviewer-3', 'reviewer-1'] }, - { state: 'COMMENTED', users: ['reviewer-2'] }, - ]); - }); - - it('handles no PR reviews yet', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getLatestReviewForReviewers(mockNotification); - - expect(result).toBeNull(); - }); - - it('returns null when not a PR notification', async () => { - mockNotification.subject.type = 'Issue'; - - const result = await getLatestReviewForReviewers(mockNotification); - - expect(result).toBeNull(); - }); - }); - - describe('Pull Requests With Labels', () => { - it('with labels', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [{ name: 'enhancement' }], - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/issues/comments/302888448', - ) - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: ['enhancement'], - linkedIssues: [], - }); - }); - - it('handle null labels', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: null, - }); - - nock('https://api.github.com') - .get( - '/repos/gitify-app/notifications-test/issues/comments/302888448', - ) - .reply(200, { user: mockCommenter }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1/reviews') - .reply(200, []); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - number: 123, - state: 'open', - user: { - login: mockCommenter.login, - html_url: mockCommenter.html_url, - avatar_url: mockCommenter.avatar_url, - type: mockCommenter.type, - }, - reviews: null, - labels: [], - linkedIssues: [], - }); - }); - }); - - describe('Pull Request With Linked Issues', () => { - it('returns empty if no pr body', () => { - const mockPr = { - user: { - type: 'User', - }, - body: null, - } as PullRequest; - - const result = parseLinkedIssuesFromPr(mockPr); - expect(result).toEqual([]); - }); - - it('returns empty if pr from non-user', () => { - const mockPr = { - user: { - type: 'Bot', - }, - body: 'This PR is linked to #1, #2, and #3', - } as PullRequest; - const result = parseLinkedIssuesFromPr(mockPr); - expect(result).toEqual([]); - }); - - it('returns linked issues', () => { - const mockPr = { - user: { - type: 'User', - }, - body: 'This PR is linked to #1, #2, and #3', - } as PullRequest; - const result = parseLinkedIssuesFromPr(mockPr); - expect(result).toEqual(['#1', '#2', '#3']); - }); - }); - - it('early return if pull request state filtered', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); - - expect(result).toEqual(null); - }); - - it('early return if pull request user filtered', async () => { - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/1') - .reply(200, { - number: 123, - state: 'open', - draft: false, - merged: false, - user: mockAuthor, - labels: [], - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/comments/302888448') - .reply(200, { user: mockCommenter }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterUserTypes: ['Bot'], - }); - - expect(result).toEqual(null); - }); - }); - - describe('Releases', () => { - it('release notification', async () => { - const mockNotification = partialMockNotification({ - title: 'This is a mock release', - type: 'Release', - url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/releases/1') - .reply(200, { author: mockAuthor }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: null, - user: { - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }, - }); - }); - - it('return early if release state filtered', async () => { - const mockNotification = partialMockNotification({ - title: 'This is a mock release', - type: 'Release', - url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, - }); - - const result = await getGitifySubjectDetails(mockNotification, { - ...mockSettings, - filterStates: ['closed'], - }); - - expect(result).toEqual(null); - }); - }); - - describe('WorkflowRuns - GitHub Actions', () => { - it('deploy review workflow run state', async () => { - const mockNotification = partialMockNotification({ - title: 'some-user requested your review to deploy to an environment', - type: 'WorkflowRun', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toEqual({ - state: 'waiting', - user: null, - }); - }); - - it('unknown workflow run state', async () => { - const mockNotification = partialMockNotification({ - title: - 'some-user requested your unknown-state to deploy to an environment', - type: 'WorkflowRun', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toBeNull(); - }); - - it('unhandled workflow run title', async () => { - const mockNotification = partialMockNotification({ - title: 'unhandled workflow run structure', - type: 'WorkflowRun', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toBeNull(); - }); - }); - - describe('Default', () => { - it('unhandled subject details', async () => { - const mockNotification = partialMockNotification({ - title: - 'There is no special subject handling for this notification type', - type: 'RepositoryInvitation', - }); - - const result = await getGitifySubjectDetails( - mockNotification, - mockSettings, - ); - - expect(result).toBeNull(); - }); - }); - - describe('Error', () => { - it('catches error and logs message', async () => { - const logErrorSpy = jest.spyOn(logger, 'logError').mockImplementation(); - - const mockError = new Error('Test error'); - const mockNotification = partialMockNotification({ - title: 'This issue will throw an error', - type: 'Issue', - url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, - }); - const mockRepository = { - full_name: 'gitify-app/notifications-test', - } as Repository; - mockNotification.repository = mockRepository; - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/1') - .replyWithError(mockError); - - await getGitifySubjectDetails(mockNotification, mockSettings); - - expect(logErrorSpy).toHaveBeenCalledWith( - 'getGitifySubjectDetails', - 'failed to fetch details for notification for', - mockError, - mockNotification, - ); - }); - }); - }); - - describe('getCheckSuiteState', () => { - it('cancelled check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run cancelled for feature/foo branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: null, - status: 'cancelled', - statusDisplayName: 'cancelled', - branchName: 'feature/foo', - }); - }); - - it('failed check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run failed for main branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: null, - status: 'failure', - statusDisplayName: 'failed', - branchName: 'main', - }); - }); - - it('multiple attempts failed check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run, Attempt #3 failed for main branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: 3, - status: 'failure', - statusDisplayName: 'failed', - branchName: 'main', - }); - }); - - it('skipped check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run skipped for main branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: null, - status: 'skipped', - statusDisplayName: 'skipped', - branchName: 'main', - }); - }); - - it('successful check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run succeeded for main branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: null, - status: 'success', - statusDisplayName: 'succeeded', - branchName: 'main', - }); - }); - - it('unknown check suite state', async () => { - const mockNotification = partialMockNotification({ - title: 'Demo workflow run unknown-status for main branch', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toEqual({ - workflowName: 'Demo', - attemptNumber: null, - status: null, - statusDisplayName: 'unknown-status', - branchName: 'main', - }); - }); - - it('unhandled check suite title', async () => { - const mockNotification = partialMockNotification({ - title: 'A title that is not in the structure we expect', - type: 'CheckSuite', - }); - - const result = getCheckSuiteAttributes(mockNotification); - - expect(result).toBeNull(); - }); - }); - - describe('getWorkflowRunState', () => { - it('deploy review workflow run state', async () => { - const mockNotification = partialMockNotification({ - title: 'some-user requested your review to deploy to an environment', - type: 'WorkflowRun', - }); - - const result = getWorkflowRunAttributes(mockNotification); - - expect(result).toEqual({ - status: 'waiting', - statusDisplayName: 'review', - user: 'some-user', - }); - }); - - it('unknown workflow run state', async () => { - const mockNotification = partialMockNotification({ - title: - 'some-user requested your unknown-state to deploy to an environment', - type: 'WorkflowRun', - }); - - const result = getWorkflowRunAttributes(mockNotification); - - expect(result).toEqual({ - status: null, - statusDisplayName: 'unknown-state', - user: 'some-user', - }); - }); - - it('unhandled workflow run title', async () => { - const mockNotification = partialMockNotification({ - title: 'unhandled workflow run structure', - type: 'WorkflowRun', - }); - - const result = getWorkflowRunAttributes(mockNotification); - - expect(result).toBeNull(); - }); - }); - - describe('getSubjectUser', () => { - it('returns null when all users are null', () => { - const result = getSubjectUser([null, null]); - - expect(result).toBeNull(); - }); - - it('returns first user', () => { - const result = getSubjectUser([mockAuthor, null]); - - expect(result).toEqual({ - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }); - }); - - it('returns second user if first is null', () => { - const result = getSubjectUser([null, mockAuthor]); - - expect(result).toEqual({ - login: mockAuthor.login, - html_url: mockAuthor.html_url, - avatar_url: mockAuthor.avatar_url, - type: mockAuthor.type, - }); - }); - }); -}); - -function mockDiscussionNode( - state: DiscussionStateType, - isAnswered: boolean, -): Discussion { - return { - number: 123, - title: 'This is a mock discussion', - url: 'https://github.com/gitify-app/notifications-test/discussions/1' as Link, - stateReason: state, - isAnswered: isAnswered, - author: mockDiscussionAuthor, - comments: { - nodes: [], - totalCount: 0, - }, - labels: null, - }; -} +// import axios from 'axios'; +// import nock from 'nock'; + +// import { +// partialMockNotification, +// partialMockUser, +// } from '../__mocks__/partial-mocks'; +// import type { Link } from '../types'; +// import type { +// Discussion, +// DiscussionAuthor, +// DiscussionStateType, +// Notification, +// PullRequest, +// Repository, +// } from '../typesGitHub'; +// import { +// getGitifySubjectDetails, +// } from './subject'; + +// const mockAuthor = partialMockUser('some-author'); +// const mockCommenter = partialMockUser('some-commenter'); +// const mockDiscussionAuthor: DiscussionAuthor = { +// login: 'discussion-author', +// url: 'https://github.com/discussion-author' as Link, +// avatar_url: 'https://avatars.githubusercontent.com/u/123456789?v=4' as Link, +// type: 'User', +// }; + +// import * as logger from '../../shared/logger'; +// import { mockSettings } from '../__mocks__/state-mocks'; + +// describe('renderer/utils/subject.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('getGitifySubjectDetails', () => { +// describe('CheckSuites - GitHub Actions', () => { +// it('cancelled check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run cancelled for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'cancelled', +// user: null, +// }); +// }); + +// it('failed check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run failed for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'failure', +// user: null, +// }); +// }); + +// it('failed at startup check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run failed at startup for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'failure', +// user: null, +// }); +// }); + +// it('multiple attempts failed check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run, Attempt #3 failed for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'failure', +// user: null, +// }); +// }); + +// it('skipped check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run skipped for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'skipped', +// user: null, +// }); +// }); + +// it('successful check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run succeeded for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'success', +// user: null, +// }); +// }); + +// it('unknown check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run unknown-status for main branch', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toBeNull(); +// }); + +// it('unhandled check suite title', async () => { +// const mockNotification = partialMockNotification({ +// title: 'A title that is not in the structure we expect', +// type: 'CheckSuite', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toBeNull(); +// }); +// }); +// describe('Commits', () => { +// it('get commit commenter', async () => { +// const mockNotification = partialMockNotification({ +// title: 'This is a commit with comments', +// type: 'Commit', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, +// latest_comment_url: +// 'https://api.github.com/repos/gitify-app/notifications-test/comments/141012658' as Link, +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', +// ) +// .reply(200, { author: mockAuthor }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/comments/141012658') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: null, +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// }); +// }); + +// it('get commit without commenter', async () => { +// const mockNotification = partialMockNotification({ +// title: 'This is a commit with comments', +// type: 'Commit', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, +// latest_comment_url: null, +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', +// ) +// .reply(200, { author: mockAuthor }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: null, +// user: { +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }, +// }); +// }); + +// it('return early if commit state filtered', async () => { +// const mockNotification = partialMockNotification({ +// title: 'This is a commit with comments', +// type: 'Commit', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, +// latest_comment_url: null, +// }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterStates: ['closed'], +// }); + +// expect(result).toEqual(null); +// }); +// }); + +// describe('Discussions', () => { +// const partialRepository: Partial = { +// full_name: 'gitify-app/notifications-test', +// }; + +// const mockNotification = partialMockNotification({ +// title: 'This is a mock discussion', +// type: 'Discussion', +// }); +// mockNotification.updated_at = '2024-01-01T00:00:00Z'; +// mockNotification.repository = { +// ...(partialRepository as Repository), +// }; + +// it('answered discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode(null, true)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'ANSWERED', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('duplicate discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode('DUPLICATE', false)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'DUPLICATE', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('open discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode(null, false)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'OPEN', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('outdated discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode('OUTDATED', false)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'OUTDATED', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('reopened discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode('REOPENED', false)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'REOPENED', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('resolved discussion state', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode('RESOLVED', true)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'RESOLVED', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: [], +// }); +// }); + +// it('discussion with labels', async () => { +// const mockDiscussion = mockDiscussionNode(null, true); +// mockDiscussion.labels = { +// nodes: [ +// { +// name: 'enhancement', +// }, +// ], +// }; +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussion], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'ANSWERED', +// user: { +// login: mockDiscussionAuthor.login, +// html_url: mockDiscussionAuthor.url, +// avatar_url: mockDiscussionAuthor.avatar_url, +// type: mockDiscussionAuthor.type, +// }, +// comments: 0, +// labels: ['enhancement'], +// }); +// }); + +// it('early return if discussion state filtered', async () => { +// nock('https://api.github.com') +// .post('/graphql') +// .reply(200, { +// data: { +// search: { +// nodes: [mockDiscussionNode(null, false)], +// }, +// }, +// }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterStates: ['closed'], +// }); + +// expect(result).toEqual(null); +// }); +// }); + +// describe('Issues', () => { +// let mockNotification: Notification; + +// beforeEach(() => { +// mockNotification = partialMockNotification({ +// title: 'This is a mock issue', +// type: 'Issue', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, +// latest_comment_url: +// 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, +// }); +// }); + +// it('open issue state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); + +// it('closed issue state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'closed', +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'closed', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); + +// it('completed issue state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'closed', +// state_reason: 'completed', +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'completed', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); + +// it('not_planned issue state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// state_reason: 'not_planned', +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'not_planned', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); + +// it('reopened issue state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// state_reason: 'reopened', +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'reopened', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); + +// it('handle issues without latest_comment_url', async () => { +// mockNotification.subject.latest_comment_url = null; + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }, +// labels: [], +// }); +// }); + +// describe('Issue With Labels', () => { +// it('with labels', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// user: mockAuthor, +// labels: [{ name: 'enhancement' }], +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/issues/comments/302888448', +// ) +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: ['enhancement'], +// }); +// }); + +// it('handle null labels', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// user: mockAuthor, +// labels: null, +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/issues/comments/302888448', +// ) +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// labels: [], +// }); +// }); +// }); + +// it('early return if issue state filtered out', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .reply(200, { +// number: 123, +// state: 'open', +// user: mockAuthor, +// labels: [], +// }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterStates: ['closed'], +// }); + +// expect(result).toEqual(null); +// }); +// }); + +// describe('Pull Requests', () => { +// let mockNotification: Notification; + +// beforeEach(() => { +// mockNotification = partialMockNotification({ +// title: 'This is a mock pull request', +// type: 'PullRequest', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/pulls/1' as Link, +// latest_comment_url: +// 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, +// }); +// }); + +// it('closed pull request state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'closed', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'closed', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// it('draft pull request state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: true, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'draft', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// it('merged pull request state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: true, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'merged', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// it('open pull request state', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// it('avoid fetching comments if latest_comment_url and url are the same', async () => { +// mockNotification.subject.latest_comment_url = +// mockNotification.subject.url; + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// it('handle pull request without latest_comment_url', async () => { +// mockNotification.subject.latest_comment_url = null; + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); + +// describe('Pull Request Reviews - Latest Reviews By Reviewer', () => { +// it('returns latest review state per reviewer', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, [ +// { +// user: { +// login: 'reviewer-1', +// }, +// state: 'REQUESTED_CHANGES', +// }, +// { +// user: { +// login: 'reviewer-2', +// }, +// state: 'COMMENTED', +// }, +// { +// user: { +// login: 'reviewer-1', +// }, +// state: 'APPROVED', +// }, +// { +// user: { +// login: 'reviewer-3', +// }, +// state: 'APPROVED', +// }, +// ]); + +// const result = await getLatestReviewForReviewers(mockNotification); + +// expect(result).toEqual([ +// { state: 'APPROVED', users: ['reviewer-3', 'reviewer-1'] }, +// { state: 'COMMENTED', users: ['reviewer-2'] }, +// ]); +// }); + +// it('handles no PR reviews yet', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getLatestReviewForReviewers(mockNotification); + +// expect(result).toBeNull(); +// }); + +// it('returns null when not a PR notification', async () => { +// mockNotification.subject.type = 'Issue'; + +// const result = await getLatestReviewForReviewers(mockNotification); + +// expect(result).toBeNull(); +// }); +// }); + +// describe('Pull Requests With Labels', () => { +// it('with labels', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [{ name: 'enhancement' }], +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/issues/comments/302888448', +// ) +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: ['enhancement'], +// linkedIssues: [], +// }); +// }); + +// it('handle null labels', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: null, +// }); + +// nock('https://api.github.com') +// .get( +// '/repos/gitify-app/notifications-test/issues/comments/302888448', +// ) +// .reply(200, { user: mockCommenter }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') +// .reply(200, []); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// number: 123, +// state: 'open', +// user: { +// login: mockCommenter.login, +// html_url: mockCommenter.html_url, +// avatar_url: mockCommenter.avatar_url, +// type: mockCommenter.type, +// }, +// reviews: null, +// labels: [], +// linkedIssues: [], +// }); +// }); +// }); + +// describe('Pull Request With Linked Issues', () => { +// it('returns empty if no pr body', () => { +// const mockPr = { +// user: { +// type: 'User', +// }, +// body: null, +// } as PullRequest; + +// const result = parseLinkedIssuesFromPr(mockPr); +// expect(result).toEqual([]); +// }); + +// it('returns empty if pr from non-user', () => { +// const mockPr = { +// user: { +// type: 'Bot', +// }, +// body: 'This PR is linked to #1, #2, and #3', +// } as PullRequest; +// const result = parseLinkedIssuesFromPr(mockPr); +// expect(result).toEqual([]); +// }); + +// it('returns linked issues', () => { +// const mockPr = { +// user: { +// type: 'User', +// }, +// body: 'This PR is linked to #1, #2, and #3', +// } as PullRequest; +// const result = parseLinkedIssuesFromPr(mockPr); +// expect(result).toEqual(['#1', '#2', '#3']); +// }); +// }); + +// it('early return if pull request state filtered', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterStates: ['closed'], +// }); + +// expect(result).toEqual(null); +// }); + +// it('early return if pull request user filtered', async () => { +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/pulls/1') +// .reply(200, { +// number: 123, +// state: 'open', +// draft: false, +// merged: false, +// user: mockAuthor, +// labels: [], +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') +// .reply(200, { user: mockCommenter }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterUserTypes: ['Bot'], +// }); + +// expect(result).toEqual(null); +// }); +// }); + +// describe('Releases', () => { +// it('release notification', async () => { +// const mockNotification = partialMockNotification({ +// title: 'This is a mock release', +// type: 'Release', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, +// latest_comment_url: +// 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, +// }); + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/releases/1') +// .reply(200, { author: mockAuthor }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: null, +// user: { +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }, +// }); +// }); + +// it('return early if release state filtered', async () => { +// const mockNotification = partialMockNotification({ +// title: 'This is a mock release', +// type: 'Release', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, +// latest_comment_url: +// 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, +// }); + +// const result = await getGitifySubjectDetails(mockNotification, { +// ...mockSettings, +// filterStates: ['closed'], +// }); + +// expect(result).toEqual(null); +// }); +// }); + +// describe('WorkflowRuns - GitHub Actions', () => { +// it('deploy review workflow run state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'some-user requested your review to deploy to an environment', +// type: 'WorkflowRun', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toEqual({ +// state: 'waiting', +// user: null, +// }); +// }); + +// it('unknown workflow run state', async () => { +// const mockNotification = partialMockNotification({ +// title: +// 'some-user requested your unknown-state to deploy to an environment', +// type: 'WorkflowRun', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toBeNull(); +// }); + +// it('unhandled workflow run title', async () => { +// const mockNotification = partialMockNotification({ +// title: 'unhandled workflow run structure', +// type: 'WorkflowRun', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toBeNull(); +// }); +// }); + +// describe('Default', () => { +// it('unhandled subject details', async () => { +// const mockNotification = partialMockNotification({ +// title: +// 'There is no special subject handling for this notification type', +// type: 'RepositoryInvitation', +// }); + +// const result = await getGitifySubjectDetails( +// mockNotification, +// mockSettings, +// ); + +// expect(result).toBeNull(); +// }); +// }); + +// describe('Error', () => { +// it('catches error and logs message', async () => { +// const logErrorSpy = jest.spyOn(logger, 'logError').mockImplementation(); + +// const mockError = new Error('Test error'); +// const mockNotification = partialMockNotification({ +// title: 'This issue will throw an error', +// type: 'Issue', +// url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, +// }); +// const mockRepository = { +// full_name: 'gitify-app/notifications-test', +// } as Repository; +// mockNotification.repository = mockRepository; + +// nock('https://api.github.com') +// .get('/repos/gitify-app/notifications-test/issues/1') +// .replyWithError(mockError); + +// await getGitifySubjectDetails(mockNotification, mockSettings); + +// expect(logErrorSpy).toHaveBeenCalledWith( +// 'getGitifySubjectDetails', +// 'failed to fetch details for notification for', +// mockError, +// mockNotification, +// ); +// }); +// }); +// }); + +// describe('getCheckSuiteState', () => { +// it('cancelled check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run cancelled for feature/foo branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: null, +// status: 'cancelled', +// statusDisplayName: 'cancelled', +// branchName: 'feature/foo', +// }); +// }); + +// it('failed check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run failed for main branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: null, +// status: 'failure', +// statusDisplayName: 'failed', +// branchName: 'main', +// }); +// }); + +// it('multiple attempts failed check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run, Attempt #3 failed for main branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: 3, +// status: 'failure', +// statusDisplayName: 'failed', +// branchName: 'main', +// }); +// }); + +// it('skipped check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run skipped for main branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: null, +// status: 'skipped', +// statusDisplayName: 'skipped', +// branchName: 'main', +// }); +// }); + +// it('successful check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run succeeded for main branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: null, +// status: 'success', +// statusDisplayName: 'succeeded', +// branchName: 'main', +// }); +// }); + +// it('unknown check suite state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'Demo workflow run unknown-status for main branch', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toEqual({ +// workflowName: 'Demo', +// attemptNumber: null, +// status: null, +// statusDisplayName: 'unknown-status', +// branchName: 'main', +// }); +// }); + +// it('unhandled check suite title', async () => { +// const mockNotification = partialMockNotification({ +// title: 'A title that is not in the structure we expect', +// type: 'CheckSuite', +// }); + +// const result = getCheckSuiteAttributes(mockNotification); + +// expect(result).toBeNull(); +// }); +// }); + +// describe('getWorkflowRunState', () => { +// it('deploy review workflow run state', async () => { +// const mockNotification = partialMockNotification({ +// title: 'some-user requested your review to deploy to an environment', +// type: 'WorkflowRun', +// }); + +// const result = getWorkflowRunAttributes(mockNotification); + +// expect(result).toEqual({ +// status: 'waiting', +// statusDisplayName: 'review', +// user: 'some-user', +// }); +// }); + +// it('unknown workflow run state', async () => { +// const mockNotification = partialMockNotification({ +// title: +// 'some-user requested your unknown-state to deploy to an environment', +// type: 'WorkflowRun', +// }); + +// const result = getWorkflowRunAttributes(mockNotification); + +// expect(result).toEqual({ +// status: null, +// statusDisplayName: 'unknown-state', +// user: 'some-user', +// }); +// }); + +// it('unhandled workflow run title', async () => { +// const mockNotification = partialMockNotification({ +// title: 'unhandled workflow run structure', +// type: 'WorkflowRun', +// }); + +// const result = getWorkflowRunAttributes(mockNotification); + +// expect(result).toBeNull(); +// }); +// }); + +// describe('getSubjectUser', () => { +// it('returns null when all users are null', () => { +// const result = getSubjectUser([null, null]); + +// expect(result).toBeNull(); +// }); + +// it('returns first user', () => { +// const result = getSubjectUser([mockAuthor, null]); + +// expect(result).toEqual({ +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }); +// }); + +// it('returns second user if first is null', () => { +// const result = getSubjectUser([null, mockAuthor]); + +// expect(result).toEqual({ +// login: mockAuthor.login, +// html_url: mockAuthor.html_url, +// avatar_url: mockAuthor.avatar_url, +// type: mockAuthor.type, +// }); +// }); +// }); +// }); + +// function mockDiscussionNode( +// state: DiscussionStateType, +// isAnswered: boolean, +// ): Discussion { +// return { +// number: 123, +// title: 'This is a mock discussion', +// url: 'https://github.com/gitify-app/notifications-test/discussions/1' as Link, +// stateReason: state, +// isAnswered: isAnswered, +// author: mockDiscussionAuthor, +// comments: { +// nodes: [], +// totalCount: 0, +// }, +// labels: null, +// }; +// } diff --git a/src/renderer/utils/subject.ts b/src/renderer/utils/subject.ts deleted file mode 100644 index dcb1e6065..000000000 --- a/src/renderer/utils/subject.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { differenceInMilliseconds } from 'date-fns'; - -import { logError } from '../../shared/logger'; -import type { Link, SettingsState } from '../types'; -import type { - CheckSuiteAttributes, - CheckSuiteStatus, - DiscussionComment, - DiscussionStateType, - GitifyPullRequestReview, - GitifySubject, - Notification, - PullRequest, - PullRequestReview, - PullRequestStateType, - StateType, - SubjectUser, - User, - WorkflowRunAttributes, -} from '../typesGitHub'; -import { - getCommit, - getCommitComment, - getIssue, - getIssueOrPullRequestComment, - getLatestDiscussion, - getPullRequest, - getPullRequestReviews, - getRelease, -} from './api/client'; -import { - isStateFilteredOut, - isUserFilteredOut, -} from './notifications/filters/filter'; - -export async function getGitifySubjectDetails( - notification: Notification, - settings: SettingsState, -): Promise { - try { - switch (notification.subject.type) { - case 'CheckSuite': - return getGitifySubjectForCheckSuite(notification); - case 'Commit': - return getGitifySubjectForCommit(notification, settings); - case 'Discussion': - return await getGitifySubjectForDiscussion(notification, settings); - case 'Issue': - return await getGitifySubjectForIssue(notification, settings); - case 'PullRequest': - return await getGitifySubjectForPullRequest(notification, settings); - case 'Release': - return await getGitifySubjectForRelease(notification, settings); - case 'WorkflowRun': - return getGitifySubjectForWorkflowRun(notification); - default: - return null; - } - } catch (err) { - logError( - 'getGitifySubjectDetails', - 'failed to fetch details for notification for', - err, - notification, - ); - } -} - -/** - * Ideally we would be using a GitHub API to fetch the CheckSuite / WorkflowRun state, - * but there isn't an obvious/clean way to do this currently. - */ -export function getCheckSuiteAttributes( - notification: Notification, -): CheckSuiteAttributes | null { - const regexPattern = - /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; - - const matches = regexPattern.exec(notification.subject.title); - - if (matches) { - const { groups } = matches; - - return { - workflowName: groups.workflowName, - attemptNumber: groups.attemptNumber - ? Number.parseInt(groups.attemptNumber) - : null, - status: getCheckSuiteStatus(groups.statusDisplayName), - statusDisplayName: groups.statusDisplayName, - branchName: groups.branchName, - }; - } - - return null; -} - -function getCheckSuiteStatus(statusDisplayName: string): CheckSuiteStatus { - switch (statusDisplayName) { - case 'cancelled': - return 'cancelled'; - case 'failed': - case 'failed at startup': - return 'failure'; - case 'skipped': - return 'skipped'; - case 'succeeded': - return 'success'; - default: - return null; - } -} - -function getGitifySubjectForCheckSuite( - notification: Notification, -): GitifySubject { - const state = getCheckSuiteAttributes(notification)?.status; - - if (state) { - return { - state: state, - user: null, - }; - } - - return null; -} - -async function getGitifySubjectForCommit( - notification: Notification, - settings: SettingsState, -): Promise { - let user: User; - const commitState: StateType = null; // Commit notifications are stateless - - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(commitState, settings)) { - return null; - } - - if (notification.subject.latest_comment_url) { - const commitComment = ( - await getCommitComment( - notification.subject.latest_comment_url, - notification.account.token, - ) - ).data; - - user = commitComment.user; - } else { - const commit = ( - await getCommit(notification.subject.url, notification.account.token) - ).data; - - user = commit.author; - } - - return { - state: commitState, - user: getSubjectUser([user]), - }; -} - -async function getGitifySubjectForDiscussion( - notification: Notification, - settings: SettingsState, -): Promise { - const discussion = await getLatestDiscussion(notification); - let discussionState: DiscussionStateType = 'OPEN'; - - if (discussion) { - if (discussion.isAnswered) { - discussionState = 'ANSWERED'; - } - - if (discussion.stateReason) { - discussionState = discussion.stateReason; - } - } - - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(discussionState, settings)) { - return null; - } - - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(discussionState, settings)) { - return null; - } - - const latestDiscussionComment = getClosestDiscussionCommentOrReply( - notification, - discussion.comments.nodes, - ); - - let discussionUser: SubjectUser = { - login: discussion.author.login, - html_url: discussion.author.url, - avatar_url: discussion.author.avatar_url, - type: discussion.author.type, - }; - if (latestDiscussionComment) { - discussionUser = { - login: latestDiscussionComment.author.login, - html_url: latestDiscussionComment.author.url, - avatar_url: latestDiscussionComment.author.avatar_url, - type: latestDiscussionComment.author.type, - }; - } - - return { - number: discussion.number, - state: discussionState, - user: discussionUser, - comments: discussion.comments.totalCount, - labels: discussion.labels?.nodes.map((label) => label.name) ?? [], - }; -} - -export function getClosestDiscussionCommentOrReply( - notification: Notification, - comments: DiscussionComment[], -): DiscussionComment | null { - if (!comments || comments.length === 0) { - return null; - } - - const targetTimestamp = notification.updated_at; - - const allCommentsAndReplies = comments.flatMap((comment) => [ - comment, - ...comment.replies.nodes, - ]); - - // Find the closest match using the target timestamp - const closestComment = allCommentsAndReplies.reduce((prev, curr) => { - const prevDiff = Math.abs( - differenceInMilliseconds(prev.createdAt, targetTimestamp), - ); - const currDiff = Math.abs( - differenceInMilliseconds(curr.createdAt, targetTimestamp), - ); - return currDiff < prevDiff ? curr : prev; - }, allCommentsAndReplies[0]); - - return closestComment; -} - -async function getGitifySubjectForIssue( - notification: Notification, - settings: SettingsState, -): Promise { - const issue = ( - await getIssue(notification.subject.url, notification.account.token) - ).data; - - const issueState = issue.state_reason ?? issue.state; - - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(issueState, settings)) { - return null; - } - - let issueCommentUser: User; - - if (notification.subject.latest_comment_url) { - const issueComment = ( - await getIssueOrPullRequestComment( - notification.subject.latest_comment_url, - notification.account.token, - ) - ).data; - issueCommentUser = issueComment.user; - } - - return { - number: issue.number, - state: issue.state_reason ?? issue.state, - user: getSubjectUser([issueCommentUser, issue.user]), - comments: issue.comments, - labels: issue.labels?.map((label) => label.name) ?? [], - milestone: issue.milestone, - }; -} - -async function getGitifySubjectForPullRequest( - notification: Notification, - settings: SettingsState, -): Promise { - const pr = ( - await getPullRequest(notification.subject.url, notification.account.token) - ).data; - - let prState: PullRequestStateType = pr.state; - if (pr.merged) { - prState = 'merged'; - } else if (pr.draft) { - prState = 'draft'; - } - - // Return early if this notification would be hidden by state filters - if (isStateFilteredOut(prState, settings)) { - return null; - } - - let prCommentUser: User; - if ( - notification.subject.latest_comment_url && - notification.subject.latest_comment_url !== notification.subject.url - ) { - const prComment = ( - await getIssueOrPullRequestComment( - notification.subject.latest_comment_url, - notification.account.token, - ) - ).data; - prCommentUser = prComment.user; - } - - const prUser = getSubjectUser([prCommentUser, pr.user]); - - // Return early if this notification would be hidden by user filters - if (isUserFilteredOut(prUser, settings)) { - return null; - } - - const reviews = await getLatestReviewForReviewers(notification); - const linkedIssues = parseLinkedIssuesFromPr(pr); - - return { - number: pr.number, - state: prState, - user: prUser, - reviews: reviews, - comments: pr.comments, - labels: pr.labels?.map((label) => label.name) ?? [], - linkedIssues: linkedIssues, - milestone: pr.milestone, - }; -} - -export async function getLatestReviewForReviewers( - notification: Notification, -): Promise | null { - if (notification.subject.type !== 'PullRequest') { - return null; - } - - const prReviews = await getPullRequestReviews( - `${notification.subject.url}/reviews` as Link, - notification.account.token, - ); - - if (!prReviews.data.length) { - return null; - } - - // Find the most recent review for each reviewer - const latestReviews: PullRequestReview[] = []; - const sortedReviews = prReviews.data.reverse(); - for (const prReview of sortedReviews) { - const reviewerFound = latestReviews.find( - (review) => review.user.login === prReview.user.login, - ); - - if (!reviewerFound) { - latestReviews.push(prReview); - } - } - - // Group by the review state - const reviewers: GitifyPullRequestReview[] = []; - for (const prReview of latestReviews) { - const reviewerFound = reviewers.find( - (review) => review.state === prReview.state, - ); - - if (!reviewerFound) { - reviewers.push({ - state: prReview.state, - users: [prReview.user.login], - }); - } else { - reviewerFound.users.push(prReview.user.login); - } - } - - // Sort reviews by state for consistent order when rendering - return reviewers.sort((a, b) => { - return a.state.localeCompare(b.state); - }); -} - -export function parseLinkedIssuesFromPr(pr: PullRequest): string[] { - const linkedIssues: string[] = []; - - if (!pr.body || pr.user.type !== 'User') { - return linkedIssues; - } - - const regexPattern = /\s?#(\d+)\s?/gi; - const matches = pr.body.matchAll(regexPattern); - - for (const match of matches) { - if (match[0]) { - linkedIssues.push(match[0].trim()); - } - } - - return linkedIssues; -} - -async function getGitifySubjectForRelease( - notification: Notification, - settings: SettingsState, -): Promise { - const releaseState: StateType = null; // Release notifications are stateless - - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(releaseState, settings)) { - return null; - } - - const release = ( - await getRelease(notification.subject.url, notification.account.token) - ).data; - - return { - state: releaseState, - user: getSubjectUser([release.author]), - }; -} - -function getGitifySubjectForWorkflowRun( - notification: Notification, -): GitifySubject { - const state = getWorkflowRunAttributes(notification)?.status; - - if (state) { - return { - state: state, - user: null, - }; - } - - return null; -} - -/** - * Ideally we would be using a GitHub API to fetch the CheckSuite / WorkflowRun state, - * but there isn't an obvious/clean way to do this currently. - */ -export function getWorkflowRunAttributes( - notification: Notification, -): WorkflowRunAttributes | null { - const regexPattern = - /^(?.*?) requested your (?.*?) to deploy to an environment$/; - - const matches = regexPattern.exec(notification.subject.title); - - if (matches) { - const { groups } = matches; - - return { - user: groups.user, - status: getWorkflowRunStatus(groups.statusDisplayName), - statusDisplayName: groups.statusDisplayName, - }; - } - - return null; -} - -function getWorkflowRunStatus(statusDisplayName: string): CheckSuiteStatus { - switch (statusDisplayName) { - case 'review': - return 'waiting'; - default: - return null; - } -} - -/** - * Construct the notification subject user based on an order prioritized list of users - * @param users array of users in order or priority - * @returns the subject user - */ -export function getSubjectUser(users: User[]): SubjectUser { - let subjectUser: SubjectUser = null; - - for (const user of users) { - if (user) { - subjectUser = { - login: user.login, - html_url: user.html_url, - avatar_url: user.avatar_url, - type: user.type, - }; - - return subjectUser; - } - } - - return subjectUser; -} From 7f213dcb3d75f2ca6a49298d74d6e086be0c1fdb Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 16:22:01 -0400 Subject: [PATCH 02/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/__helpers__/jest.setup.ts | 7 +- src/renderer/__mocks__/notifications-mocks.ts | 15 + .../SettingsFooter.test.tsx.snap | 26 + src/renderer/utils/icons.test.ts | 697 +++---- .../notifications/handlers/checkSuite.test.ts | 297 +++ .../notifications/handlers/commit.test.ts | 98 + .../utils/notifications/handlers/commit.ts | 13 +- .../notifications/handlers/default.test.ts | 29 + .../utils/notifications/handlers/default.ts | 2 +- .../notifications/handlers/discussion.test.ts | 318 ++++ .../notifications/handlers/discussion.ts | 5 - .../utils/notifications/handlers/index.ts | 12 +- .../notifications/handlers/issue.test.ts | 338 ++++ .../handlers/pullRequest.test.ts | 525 ++++++ .../notifications/handlers/release.test.ts | 72 + .../repositoryDependabotAlertsThread.test.ts | 14 + ...ts => repositoryDependabotAlertsThread.ts} | 0 .../handlers/repositoryInvitation.test.ts | 14 + .../repositoryVulnerabilityAlert.test.ts | 14 + .../notifications/handlers/utils.test.ts | 36 + .../handlers/workflowRun.test.ts | 108 ++ .../utils/notifications/notifications.test.ts | 37 +- .../utils/notifications/notifications.ts | 63 +- src/renderer/utils/subject.test.ts | 1630 ----------------- 24 files changed, 2249 insertions(+), 2121 deletions(-) create mode 100644 src/renderer/utils/notifications/handlers/checkSuite.test.ts create mode 100644 src/renderer/utils/notifications/handlers/commit.test.ts create mode 100644 src/renderer/utils/notifications/handlers/default.test.ts create mode 100644 src/renderer/utils/notifications/handlers/discussion.test.ts create mode 100644 src/renderer/utils/notifications/handlers/issue.test.ts create mode 100644 src/renderer/utils/notifications/handlers/pullRequest.test.ts create mode 100644 src/renderer/utils/notifications/handlers/release.test.ts create mode 100644 src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.test.ts rename src/renderer/utils/notifications/handlers/{repositoryDependabotAlertsThread copy.ts => repositoryDependabotAlertsThread.ts} (100%) create mode 100644 src/renderer/utils/notifications/handlers/repositoryInvitation.test.ts create mode 100644 src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.test.ts create mode 100644 src/renderer/utils/notifications/handlers/utils.test.ts create mode 100644 src/renderer/utils/notifications/handlers/workflowRun.test.ts delete mode 100644 src/renderer/utils/subject.test.ts diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 21c886ea6..27fade6d4 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -1,7 +1,8 @@ import '@testing-library/jest-dom'; - import { TextDecoder, TextEncoder } from 'node:util'; +import axios from 'axios'; + /** * Prevent the following errors with jest: * - ReferenceError: TextEncoder is not defined @@ -36,3 +37,7 @@ global.ResizeObserver = class { unobserve() {} disconnect() {} }; + +// 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'; diff --git a/src/renderer/__mocks__/notifications-mocks.ts b/src/renderer/__mocks__/notifications-mocks.ts index 0abbe39cc..1bcae1fc1 100644 --- a/src/renderer/__mocks__/notifications-mocks.ts +++ b/src/renderer/__mocks__/notifications-mocks.ts @@ -1,4 +1,5 @@ import type { AccountNotifications } from '../types'; +import type { StateType, Subject, SubjectType } from '../typesGitHub'; import { mockEnterpriseNotifications, mockGitHubNotifications, @@ -28,3 +29,17 @@ export const mockSingleAccountNotifications: AccountNotifications[] = [ error: null, }, ]; + +export function createSubjectMock(mocks: { + title?: string; + type?: SubjectType; + state?: StateType; +}): Subject { + return { + title: mocks.title ?? 'Mock Subject', + type: mocks.type ?? ('Unknown' as SubjectType), + state: mocks.state ?? ('Unknown' as StateType), + url: null, + latest_comment_url: null, + }; +} diff --git a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap index 36e0732a8..e42608a42 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap @@ -51,3 +51,29 @@ exports[`renderer/components/settings/SettingsFooter.tsx app version should show `; + +exports[`renderer/components/settings/SettingsFooter.tsx should open release notes 1`] = ` + +`; diff --git a/src/renderer/utils/icons.test.ts b/src/renderer/utils/icons.test.ts index 3ca9618b9..4ecf91c4a 100644 --- a/src/renderer/utils/icons.test.ts +++ b/src/renderer/utils/icons.test.ts @@ -1,446 +1,251 @@ -// import { -// CheckIcon, -// CommentIcon, -// FeedPersonIcon, -// FileDiffIcon, -// MarkGithubIcon, -// OrganizationIcon, -// } from '@primer/octicons-react'; - -// import { IconColor } from '../types'; -// import type { -// GitifyPullRequestReview, -// StateType, -// Subject, -// SubjectType, -// } from '../typesGitHub'; -// import { -// getAuthMethodIcon, -// getDefaultUserIcon, -// getNotificationTypeIcon, -// getNotificationTypeIconColor, -// getPlatformIcon, -// getPullRequestReviewIcon, -// } from './icons'; - -// describe('renderer/utils/icons.ts', () => { -// describe('getNotificationTypeIcon - should get the notification type icon', () => { -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ type: 'CheckSuite', state: null }), -// ).displayName, -// ).toBe('RocketIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'cancelled', -// }), -// ).displayName, -// ).toBe('StopIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'failure', -// }), -// ).displayName, -// ).toBe('XIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'skipped', -// }), -// ).displayName, -// ).toBe('SkipIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'success', -// }), -// ).displayName, -// ).toBe('CheckIcon'); - -// expect( -// getNotificationTypeIcon(createSubjectMock({ type: 'Commit' })) -// .displayName, -// ).toBe('GitCommitIcon'); - -// expect( -// getNotificationTypeIcon(createSubjectMock({ type: 'Discussion' })) -// .displayName, -// ).toBe('CommentDiscussionIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ type: 'Discussion', state: 'DUPLICATE' }), -// ).displayName, -// ).toBe('DiscussionDuplicateIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ type: 'Discussion', state: 'OUTDATED' }), -// ).displayName, -// ).toBe('DiscussionOutdatedIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ type: 'Discussion', state: 'RESOLVED' }), -// ).displayName, -// ).toBe('DiscussionClosedIcon'); - -// expect( -// getNotificationTypeIcon(createSubjectMock({ type: 'Issue' })).displayName, -// ).toBe('IssueOpenedIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ type: 'Issue', state: 'draft' }), -// ).displayName, -// ).toBe('IssueDraftIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'Issue', -// state: 'closed', -// }), -// ).displayName, -// ).toBe('IssueClosedIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'Issue', -// state: 'completed', -// }), -// ).displayName, -// ).toBe('IssueClosedIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'Issue', -// state: 'not_planned', -// }), -// ).displayName, -// ).toBe('SkipIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'Issue', -// state: 'reopened', -// }), -// ).displayName, -// ).toBe('IssueReopenedIcon'); - -// expect( -// getNotificationTypeIcon(createSubjectMock({ type: 'PullRequest' })) -// .displayName, -// ).toBe('GitPullRequestIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'PullRequest', -// state: 'draft', -// }), -// ).displayName, -// ).toBe('GitPullRequestDraftIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'PullRequest', -// state: 'closed', -// }), -// ).displayName, -// ).toBe('GitPullRequestClosedIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'PullRequest', -// state: 'merged', -// }), -// ).displayName, -// ).toBe('GitMergeIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'Release', -// }), -// ).displayName, -// ).toBe('TagIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'RepositoryDependabotAlertsThread', -// }), -// ).displayName, -// ).toBe('AlertIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'RepositoryInvitation', -// }), -// ).displayName, -// ).toBe('MailIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'RepositoryVulnerabilityAlert', -// }), -// ).displayName, -// ).toBe('AlertIcon'); - -// expect( -// getNotificationTypeIcon( -// createSubjectMock({ -// type: 'WorkflowRun', -// }), -// ).displayName, -// ).toBe('RocketIcon'); - -// expect(getNotificationTypeIcon(createSubjectMock({})).displayName).toBe( -// 'QuestionIcon', -// ); -// }); - -// describe('getNotificationTypeIconColor', () => { -// it('should format the notification color for check suite', () => { -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'cancelled', -// }), -// ), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'failure', -// }), -// ), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'skipped', -// }), -// ), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// type: 'CheckSuite', -// state: 'success', -// }), -// ), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// type: 'CheckSuite', -// state: null, -// }), -// ), -// ).toMatchSnapshot(); -// }); - -// it('should format the notification color for state', () => { -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'ANSWERED' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'closed' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'completed' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'draft' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'merged' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ state: 'not_planned' }), -// ), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'open' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'reopened' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor(createSubjectMock({ state: 'RESOLVED' })), -// ).toMatchSnapshot(); - -// expect( -// getNotificationTypeIconColor( -// createSubjectMock({ -// state: 'something_else_unknown' as StateType, -// }), -// ), -// ).toMatchSnapshot(); -// }); -// }); - -// describe('getPullRequestReviewIcon', () => { -// let mockReviewSingleReviewer: GitifyPullRequestReview; -// let mockReviewMultipleReviewer: GitifyPullRequestReview; - -// beforeEach(() => { -// mockReviewSingleReviewer = { -// state: 'APPROVED', -// users: ['user1'], -// }; -// mockReviewMultipleReviewer = { -// state: 'APPROVED', -// users: ['user1', 'user2'], -// }; -// }); - -// it('approved', () => { -// mockReviewSingleReviewer.state = 'APPROVED'; -// mockReviewMultipleReviewer.state = 'APPROVED'; - -// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ -// type: CheckIcon, -// color: IconColor.GREEN, -// description: 'user1 approved these changes', -// }); - -// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ -// type: CheckIcon, -// color: IconColor.GREEN, -// description: 'user1, user2 approved these changes', -// }); -// }); - -// it('changes requested', () => { -// mockReviewSingleReviewer.state = 'CHANGES_REQUESTED'; -// mockReviewMultipleReviewer.state = 'CHANGES_REQUESTED'; - -// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ -// type: FileDiffIcon, -// color: IconColor.RED, -// description: 'user1 requested changes', -// }); - -// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ -// type: FileDiffIcon, -// color: IconColor.RED, -// description: 'user1, user2 requested changes', -// }); -// }); - -// it('commented', () => { -// mockReviewSingleReviewer.state = 'COMMENTED'; -// mockReviewMultipleReviewer.state = 'COMMENTED'; - -// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ -// type: CommentIcon, -// color: IconColor.YELLOW, -// description: 'user1 left review comments', -// }); - -// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ -// type: CommentIcon, -// color: IconColor.YELLOW, -// description: 'user1, user2 left review comments', -// }); -// }); - -// it('dismissed', () => { -// mockReviewSingleReviewer.state = 'DISMISSED'; -// mockReviewMultipleReviewer.state = 'DISMISSED'; - -// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ -// type: CommentIcon, -// color: IconColor.GRAY, -// description: 'user1 review has been dismissed', -// }); - -// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ -// type: CommentIcon, -// color: IconColor.GRAY, -// description: 'user1, user2 reviews have been dismissed', -// }); -// }); - -// it('pending', () => { -// mockReviewSingleReviewer.state = 'PENDING'; -// mockReviewMultipleReviewer.state = 'PENDING'; - -// expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toBeNull(); - -// expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toBeNull(); -// }); -// }); - -// describe('getAuthMethodIcon', () => { -// expect(getAuthMethodIcon('GitHub App')).toMatchSnapshot(); - -// expect(getAuthMethodIcon('OAuth App')).toMatchSnapshot(); - -// expect(getAuthMethodIcon('Personal Access Token')).toMatchSnapshot(); -// }); - -// describe('getPlatformIcon', () => { -// expect(getPlatformIcon('GitHub Cloud')).toMatchSnapshot(); - -// expect(getPlatformIcon('GitHub Enterprise Server')).toMatchSnapshot(); -// }); - -// describe('getDefaultUserIcon', () => { -// expect(getDefaultUserIcon('Bot')).toBe(MarkGithubIcon); -// expect(getDefaultUserIcon('EnterpriseUserAccount')).toBe(FeedPersonIcon); -// expect(getDefaultUserIcon('Mannequin')).toBe(MarkGithubIcon); -// expect(getDefaultUserIcon('Organization')).toBe(OrganizationIcon); -// expect(getDefaultUserIcon('User')).toBe(FeedPersonIcon); -// }); -// }); - -// function createSubjectMock(mocks: { -// title?: string; -// type?: SubjectType; -// state?: StateType; -// }): Subject { -// return { -// title: mocks.title ?? 'Mock Subject', -// type: mocks.type ?? ('Unknown' as SubjectType), -// state: mocks.state ?? ('Unknown' as StateType), -// url: null, -// latest_comment_url: null, -// }; -// } +import { + CheckIcon, + CommentIcon, + FeedPersonIcon, + FileDiffIcon, + MarkGithubIcon, + OrganizationIcon, +} from '@primer/octicons-react'; + +import { IconColor } from '../types'; +import type { + GitifyPullRequestReview, + StateType, + Subject, + SubjectType, +} from '../typesGitHub'; +import { + getAuthMethodIcon, + getDefaultUserIcon, + getNotificationTypeIconColor, + getPlatformIcon, + getPullRequestReviewIcon, +} from './icons'; + +describe('renderer/utils/icons.ts', () => { + describe('getNotificationTypeIconColor', () => { + it('should format the notification color for check suite', () => { + expect( + getNotificationTypeIconColor( + createSubjectMock({ + type: 'CheckSuite', + state: 'cancelled', + }), + ), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ + type: 'CheckSuite', + state: 'failure', + }), + ), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ + type: 'CheckSuite', + state: 'skipped', + }), + ), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ + type: 'CheckSuite', + state: 'success', + }), + ), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ + type: 'CheckSuite', + state: null, + }), + ), + ).toMatchSnapshot(); + }); + + it('should format the notification color for state', () => { + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'ANSWERED' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'closed' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'completed' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'draft' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'merged' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ state: 'not_planned' }), + ), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'open' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'reopened' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor(createSubjectMock({ state: 'RESOLVED' })), + ).toMatchSnapshot(); + + expect( + getNotificationTypeIconColor( + createSubjectMock({ + state: 'something_else_unknown' as StateType, + }), + ), + ).toMatchSnapshot(); + }); + }); + + describe('getPullRequestReviewIcon', () => { + let mockReviewSingleReviewer: GitifyPullRequestReview; + let mockReviewMultipleReviewer: GitifyPullRequestReview; + + beforeEach(() => { + mockReviewSingleReviewer = { + state: 'APPROVED', + users: ['user1'], + }; + mockReviewMultipleReviewer = { + state: 'APPROVED', + users: ['user1', 'user2'], + }; + }); + + it('approved', () => { + mockReviewSingleReviewer.state = 'APPROVED'; + mockReviewMultipleReviewer.state = 'APPROVED'; + + expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ + type: CheckIcon, + color: IconColor.GREEN, + description: 'user1 approved these changes', + }); + + expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ + type: CheckIcon, + color: IconColor.GREEN, + description: 'user1, user2 approved these changes', + }); + }); + + it('changes requested', () => { + mockReviewSingleReviewer.state = 'CHANGES_REQUESTED'; + mockReviewMultipleReviewer.state = 'CHANGES_REQUESTED'; + + expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ + type: FileDiffIcon, + color: IconColor.RED, + description: 'user1 requested changes', + }); + + expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ + type: FileDiffIcon, + color: IconColor.RED, + description: 'user1, user2 requested changes', + }); + }); + + it('commented', () => { + mockReviewSingleReviewer.state = 'COMMENTED'; + mockReviewMultipleReviewer.state = 'COMMENTED'; + + expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ + type: CommentIcon, + color: IconColor.YELLOW, + description: 'user1 left review comments', + }); + + expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ + type: CommentIcon, + color: IconColor.YELLOW, + description: 'user1, user2 left review comments', + }); + }); + + it('dismissed', () => { + mockReviewSingleReviewer.state = 'DISMISSED'; + mockReviewMultipleReviewer.state = 'DISMISSED'; + + expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toEqual({ + type: CommentIcon, + color: IconColor.GRAY, + description: 'user1 review has been dismissed', + }); + + expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toEqual({ + type: CommentIcon, + color: IconColor.GRAY, + description: 'user1, user2 reviews have been dismissed', + }); + }); + + it('pending', () => { + mockReviewSingleReviewer.state = 'PENDING'; + mockReviewMultipleReviewer.state = 'PENDING'; + + expect(getPullRequestReviewIcon(mockReviewSingleReviewer)).toBeNull(); + + expect(getPullRequestReviewIcon(mockReviewMultipleReviewer)).toBeNull(); + }); + }); + + describe('getAuthMethodIcon', () => { + expect(getAuthMethodIcon('GitHub App')).toMatchSnapshot(); + + expect(getAuthMethodIcon('OAuth App')).toMatchSnapshot(); + + expect(getAuthMethodIcon('Personal Access Token')).toMatchSnapshot(); + }); + + describe('getPlatformIcon', () => { + expect(getPlatformIcon('GitHub Cloud')).toMatchSnapshot(); + + expect(getPlatformIcon('GitHub Enterprise Server')).toMatchSnapshot(); + }); + + describe('getDefaultUserIcon', () => { + expect(getDefaultUserIcon('Bot')).toBe(MarkGithubIcon); + expect(getDefaultUserIcon('EnterpriseUserAccount')).toBe(FeedPersonIcon); + expect(getDefaultUserIcon('Mannequin')).toBe(MarkGithubIcon); + expect(getDefaultUserIcon('Organization')).toBe(OrganizationIcon); + expect(getDefaultUserIcon('User')).toBe(FeedPersonIcon); + }); +}); + +function createSubjectMock(mocks: { + title?: string; + type?: SubjectType; + state?: StateType; +}): Subject { + return { + title: mocks.title ?? 'Mock Subject', + type: mocks.type ?? ('Unknown' as SubjectType), + state: mocks.state ?? ('Unknown' as StateType), + url: null, + latest_comment_url: null, + }; +} diff --git a/src/renderer/utils/notifications/handlers/checkSuite.test.ts b/src/renderer/utils/notifications/handlers/checkSuite.test.ts new file mode 100644 index 000000000..caaa96639 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/checkSuite.test.ts @@ -0,0 +1,297 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import { checkSuiteHandler, getCheckSuiteAttributes } from './checkSuite'; + +describe('renderer/utils/notifications/handlers/checkSuite.ts', () => { + describe('enrich', () => { + it('cancelled check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run cancelled for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'cancelled', + user: null, + }); + }); + + it('failed check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run failed for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'failure', + user: null, + }); + }); + + it('failed at startup check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run failed at startup for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'failure', + user: null, + }); + }); + + it('multiple attempts failed check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run, Attempt #3 failed for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'failure', + user: null, + }); + }); + + it('skipped check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run skipped for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'skipped', + user: null, + }); + }); + + it('successful check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run succeeded for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'success', + user: null, + }); + }); + + it('unknown check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run unknown-status for main branch', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toBeNull(); + }); + + it('unhandled check suite title', async () => { + const mockNotification = partialMockNotification({ + title: 'A title that is not in the structure we expect', + type: 'CheckSuite', + }); + + const result = await checkSuiteHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toBeNull(); + }); + }); + + it('getIcon', () => { + expect( + checkSuiteHandler.getIcon( + createSubjectMock({ type: 'CheckSuite', state: null }), + ).displayName, + ).toBe('RocketIcon'); + + expect( + checkSuiteHandler.getIcon( + createSubjectMock({ + type: 'CheckSuite', + state: 'cancelled', + }), + ).displayName, + ).toBe('StopIcon'); + + expect( + checkSuiteHandler.getIcon( + createSubjectMock({ + type: 'CheckSuite', + state: 'failure', + }), + ).displayName, + ).toBe('XIcon'); + + expect( + checkSuiteHandler.getIcon( + createSubjectMock({ + type: 'CheckSuite', + state: 'skipped', + }), + ).displayName, + ).toBe('SkipIcon'); + + expect( + checkSuiteHandler.getIcon( + createSubjectMock({ + type: 'CheckSuite', + state: 'success', + }), + ).displayName, + ).toBe('CheckIcon'); + }); + + describe('getCheckSuiteState', () => { + it('cancelled check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run cancelled for feature/foo branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: null, + status: 'cancelled', + statusDisplayName: 'cancelled', + branchName: 'feature/foo', + }); + }); + + it('failed check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run failed for main branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: null, + status: 'failure', + statusDisplayName: 'failed', + branchName: 'main', + }); + }); + + it('multiple attempts failed check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run, Attempt #3 failed for main branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: 3, + status: 'failure', + statusDisplayName: 'failed', + branchName: 'main', + }); + }); + + it('skipped check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run skipped for main branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: null, + status: 'skipped', + statusDisplayName: 'skipped', + branchName: 'main', + }); + }); + + it('successful check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run succeeded for main branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: null, + status: 'success', + statusDisplayName: 'succeeded', + branchName: 'main', + }); + }); + + it('unknown check suite state', async () => { + const mockNotification = partialMockNotification({ + title: 'Demo workflow run unknown-status for main branch', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toEqual({ + workflowName: 'Demo', + attemptNumber: null, + status: null, + statusDisplayName: 'unknown-status', + branchName: 'main', + }); + }); + + it('unhandled check suite title', async () => { + const mockNotification = partialMockNotification({ + title: 'A title that is not in the structure we expect', + type: 'CheckSuite', + }); + + const result = getCheckSuiteAttributes(mockNotification); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/commit.test.ts b/src/renderer/utils/notifications/handlers/commit.test.ts new file mode 100644 index 000000000..a99a60a6a --- /dev/null +++ b/src/renderer/utils/notifications/handlers/commit.test.ts @@ -0,0 +1,98 @@ +import nock from 'nock'; + +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { + partialMockNotification, + partialMockUser, +} from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Link } from '../../../types'; +import { commitHandler } from './commit'; + +describe('renderer/utils/notifications/handlers/commit.ts', () => { + describe('enrich', () => { + const mockAuthor = partialMockUser('some-author'); + const mockCommenter = partialMockUser('some-commenter'); + + it('get commit commenter', async () => { + const mockNotification = partialMockNotification({ + title: 'This is a commit with comments', + type: 'Commit', + url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, + latest_comment_url: + 'https://api.github.com/repos/gitify-app/notifications-test/comments/141012658' as Link, + }); + + nock('https://api.github.com') + .get( + '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', + ) + .reply(200, { author: mockAuthor }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/comments/141012658') + .reply(200, { user: mockCommenter }); + + const result = await commitHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + state: null, + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + }); + }); + + it('get commit without commenter', async () => { + const mockNotification = partialMockNotification({ + title: 'This is a commit with comments', + type: 'Commit', + url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, + latest_comment_url: null, + }); + + nock('https://api.github.com') + .get( + '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', + ) + .reply(200, { author: mockAuthor }); + + const result = await commitHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + state: null, + user: { + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }, + }); + }); + + it('return early if commit state filtered', async () => { + const mockNotification = partialMockNotification({ + title: 'This is a commit with comments', + type: 'Commit', + url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, + latest_comment_url: null, + }); + + const result = await commitHandler.enrich(mockNotification, { + ...mockSettings, + filterStates: ['closed'], + }); + + expect(result).toEqual(null); + }); + }); + + it('getIcon', () => { + expect( + commitHandler.getIcon(createSubjectMock({ type: 'Commit' })).displayName, + ).toBe('GitCommitIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/commit.ts b/src/renderer/utils/notifications/handlers/commit.ts index 2cdf697cc..a2f8c47e1 100644 --- a/src/renderer/utils/notifications/handlers/commit.ts +++ b/src/renderer/utils/notifications/handlers/commit.ts @@ -7,10 +7,12 @@ import type { SettingsState } from '../../../types'; import type { GitifySubject, Notification, + StateType, Subject, User, } from '../../../typesGitHub'; import { getCommit, getCommitComment } from '../../api/client'; +import { isStateFilteredOut } from '../filters/filter'; import type { NotificationTypeHandler } from './types'; import { getSubjectUser } from './utils'; @@ -19,8 +21,15 @@ class CommitHandler implements NotificationTypeHandler { async enrich( notification: Notification, - _settings: SettingsState, + settings: SettingsState, ): Promise { + const commitState: StateType = null; // Commit notifications are stateless + + // Return early if this notification would be hidden by filters + if (isStateFilteredOut(commitState, settings)) { + return null; + } + let user: User; if (notification.subject.latest_comment_url) { @@ -41,7 +50,7 @@ class CommitHandler implements NotificationTypeHandler { } return { - state: null, + state: commitState, user: getSubjectUser([user]), }; } diff --git a/src/renderer/utils/notifications/handlers/default.test.ts b/src/renderer/utils/notifications/handlers/default.test.ts new file mode 100644 index 000000000..8f1db8780 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/default.test.ts @@ -0,0 +1,29 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import { defaultHandler } from './default'; + +describe('renderer/utils/notifications/handlers/default.ts', () => { + describe('enrich', () => { + it('unhandled subject details', async () => { + const mockNotification = partialMockNotification({ + title: + 'There is no special subject handling for this notification type', + type: 'RepositoryInvitation', + }); + + const result = await defaultHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toBeNull(); + }); + }); + + it('getIcon', () => { + expect(defaultHandler.getIcon(createSubjectMock({})).displayName).toBe( + 'QuestionIcon', + ); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/default.ts b/src/renderer/utils/notifications/handlers/default.ts index 0b15c8a2e..8c43c407f 100644 --- a/src/renderer/utils/notifications/handlers/default.ts +++ b/src/renderer/utils/notifications/handlers/default.ts @@ -16,7 +16,7 @@ class DefaultHandler implements NotificationTypeHandler { _notification: Notification, _settings: SettingsState, ): Promise { - return; + return null; } getIcon(_subject: Subject): FC | null { diff --git a/src/renderer/utils/notifications/handlers/discussion.test.ts b/src/renderer/utils/notifications/handlers/discussion.test.ts new file mode 100644 index 000000000..712616802 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/discussion.test.ts @@ -0,0 +1,318 @@ +import nock from 'nock'; + +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Link } from '../../../types'; +import type { + Discussion, + DiscussionAuthor, + DiscussionStateType, + Repository, +} from '../../../typesGitHub'; +import { discussionHandler } from './discussion'; + +const mockDiscussionAuthor: DiscussionAuthor = { + login: 'discussion-author', + url: 'https://github.com/discussion-author' as Link, + avatar_url: 'https://avatars.githubusercontent.com/u/123456789?v=4' as Link, + type: 'User', +}; + +describe('renderer/utils/notifications/handlers/discussion.ts', () => { + describe('enrich', () => { + const partialRepository: Partial = { + full_name: 'gitify-app/notifications-test', + }; + + const mockNotification = partialMockNotification({ + title: 'This is a mock discussion', + type: 'Discussion', + }); + mockNotification.updated_at = '2024-01-01T00:00:00Z'; + mockNotification.repository = { + ...(partialRepository as Repository), + }; + + it('answered discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode(null, true)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'ANSWERED', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('duplicate discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode('DUPLICATE', false)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'DUPLICATE', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('open discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode(null, false)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'OPEN', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('outdated discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode('OUTDATED', false)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'OUTDATED', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('reopened discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode('REOPENED', false)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'REOPENED', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('resolved discussion state', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode('RESOLVED', true)], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'RESOLVED', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: [], + }); + }); + + it('discussion with labels', async () => { + const mockDiscussion = mockDiscussionNode(null, true); + mockDiscussion.labels = { + nodes: [ + { + name: 'enhancement', + }, + ], + }; + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussion], + }, + }, + }); + + const result = await discussionHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'ANSWERED', + user: { + login: mockDiscussionAuthor.login, + html_url: mockDiscussionAuthor.url, + avatar_url: mockDiscussionAuthor.avatar_url, + type: mockDiscussionAuthor.type, + }, + comments: 0, + labels: ['enhancement'], + }); + }); + + it('early return if discussion state filtered', async () => { + nock('https://api.github.com') + .post('/graphql') + .reply(200, { + data: { + search: { + nodes: [mockDiscussionNode(null, false)], + }, + }, + }); + + const result = await discussionHandler.enrich(mockNotification, { + ...mockSettings, + filterStates: ['closed'], + }); + + expect(result).toEqual(null); + }); + }); + + it('getIcon', () => { + expect( + discussionHandler.getIcon(createSubjectMock({ type: 'Discussion' })) + .displayName, + ).toBe('CommentDiscussionIcon'); + + expect( + discussionHandler.getIcon( + createSubjectMock({ type: 'Discussion', state: 'DUPLICATE' }), + ).displayName, + ).toBe('DiscussionDuplicateIcon'); + + expect( + discussionHandler.getIcon( + createSubjectMock({ type: 'Discussion', state: 'OUTDATED' }), + ).displayName, + ).toBe('DiscussionOutdatedIcon'); + + expect( + discussionHandler.getIcon( + createSubjectMock({ type: 'Discussion', state: 'RESOLVED' }), + ).displayName, + ).toBe('DiscussionClosedIcon'); + }); +}); + +function mockDiscussionNode( + state: DiscussionStateType, + isAnswered: boolean, +): Discussion { + return { + number: 123, + title: 'This is a mock discussion', + url: 'https://github.com/gitify-app/notifications-test/discussions/1' as Link, + stateReason: state, + isAnswered: isAnswered, + author: mockDiscussionAuthor, + comments: { + nodes: [], + totalCount: 0, + }, + labels: null, + }; +} diff --git a/src/renderer/utils/notifications/handlers/discussion.ts b/src/renderer/utils/notifications/handlers/discussion.ts index 1e185fd71..755e69176 100644 --- a/src/renderer/utils/notifications/handlers/discussion.ts +++ b/src/renderer/utils/notifications/handlers/discussion.ts @@ -48,11 +48,6 @@ class DiscussionHandler implements NotificationTypeHandler { return null; } - // Return early if this notification would be hidden by filters - if (isStateFilteredOut(discussionState, settings)) { - return null; - } - const latestDiscussionComment = getClosestDiscussionCommentOrReply( notification, discussion.comments.nodes, diff --git a/src/renderer/utils/notifications/handlers/index.ts b/src/renderer/utils/notifications/handlers/index.ts index a4f02d1de..579a67495 100644 --- a/src/renderer/utils/notifications/handlers/index.ts +++ b/src/renderer/utils/notifications/handlers/index.ts @@ -6,7 +6,7 @@ import { discussionHandler } from './discussion'; import { issueHandler } from './issue'; import { pullRequestHandler } from './pullRequest'; import { releaseHandler } from './release'; -import { repositoryDependabotAlertsThreadHandler } from './repositoryDependabotAlertsThread copy'; +import { repositoryDependabotAlertsThreadHandler } from './repositoryDependabotAlertsThread'; import { repositoryInvitationHandler } from './repositoryInvitation'; import { repositoryVulnerabilityAlertHandler } from './repositoryVulnerabilityAlert'; import type { NotificationTypeHandler } from './types'; @@ -18,8 +18,6 @@ export function createNotificationHandler( switch (notification.subject.type) { case 'CheckSuite': return checkSuiteHandler; - case 'WorkflowRun': - return workflowRunHandler; case 'Commit': return commitHandler; case 'Discussion': @@ -36,6 +34,8 @@ export function createNotificationHandler( return repositoryInvitationHandler; case 'RepositoryVulnerabilityAlert': return repositoryVulnerabilityAlertHandler; + case 'WorkflowRun': + return workflowRunHandler; default: return defaultHandler; } @@ -43,10 +43,14 @@ export function createNotificationHandler( export const handlers = { checkSuiteHandler, - workflowRunHandler, commitHandler, discussionHandler, issueHandler, pullRequestHandler, releaseHandler, + repositoryDependabotAlertsThreadHandler, + repositoryInvitationHandler, + repositoryVulnerabilityAlertHandler, + workflowRunHandler, + defaultHandler, }; diff --git a/src/renderer/utils/notifications/handlers/issue.test.ts b/src/renderer/utils/notifications/handlers/issue.test.ts new file mode 100644 index 000000000..c7ae54bda --- /dev/null +++ b/src/renderer/utils/notifications/handlers/issue.test.ts @@ -0,0 +1,338 @@ +import nock from 'nock'; + +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { + partialMockNotification, + partialMockUser, +} from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Link } from '../../../types'; +import type { Notification } from '../../../typesGitHub'; +import { issueHandler } from './issue'; + +describe('renderer/utils/notifications/handlers/issue.ts', () => { + describe('enrich', () => { + const mockAuthor = partialMockUser('some-author'); + const mockCommenter = partialMockUser('some-commenter'); + + let mockNotification: Notification; + + beforeEach(() => { + mockNotification = partialMockNotification({ + title: 'This is a mock issue', + type: 'Issue', + url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, + latest_comment_url: + 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, + }); + }); + + it('open issue state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + + it('closed issue state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'closed', + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'closed', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + + it('completed issue state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'closed', + state_reason: 'completed', + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'completed', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + + it('not_planned issue state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + state_reason: 'not_planned', + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'not_planned', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + + it('reopened issue state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + state_reason: 'reopened', + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'reopened', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + + it('handle issues without latest_comment_url', async () => { + mockNotification.subject.latest_comment_url = null; + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + const result = await issueHandler.enrich(mockNotification, mockSettings); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }, + labels: [], + }); + }); + + describe('Issue With Labels', () => { + it('with labels', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + user: mockAuthor, + labels: [{ name: 'enhancement' }], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: ['enhancement'], + }); + }); + + it('handle null labels', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + user: mockAuthor, + labels: null, + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await issueHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + labels: [], + }); + }); + }); + + it('early return if issue state filtered out', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .reply(200, { + number: 123, + state: 'open', + user: mockAuthor, + labels: [], + }); + + const result = await issueHandler.enrich(mockNotification, { + ...mockSettings, + filterStates: ['closed'], + }); + + expect(result).toEqual(null); + }); + }); + + it('getIcon', () => { + expect( + issueHandler.getIcon(createSubjectMock({ type: 'Issue' })).displayName, + ).toBe('IssueOpenedIcon'); + + expect( + issueHandler.getIcon(createSubjectMock({ type: 'Issue', state: 'draft' })) + .displayName, + ).toBe('IssueDraftIcon'); + + expect( + issueHandler.getIcon( + createSubjectMock({ + type: 'Issue', + state: 'closed', + }), + ).displayName, + ).toBe('IssueClosedIcon'); + + expect( + issueHandler.getIcon( + createSubjectMock({ + type: 'Issue', + state: 'completed', + }), + ).displayName, + ).toBe('IssueClosedIcon'); + + expect( + issueHandler.getIcon( + createSubjectMock({ + type: 'Issue', + state: 'not_planned', + }), + ).displayName, + ).toBe('SkipIcon'); + + expect( + issueHandler.getIcon( + createSubjectMock({ + type: 'Issue', + state: 'reopened', + }), + ).displayName, + ).toBe('IssueReopenedIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/pullRequest.test.ts b/src/renderer/utils/notifications/handlers/pullRequest.test.ts new file mode 100644 index 000000000..dfd521c86 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/pullRequest.test.ts @@ -0,0 +1,525 @@ +import nock from 'nock'; + +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { + partialMockNotification, + partialMockUser, +} from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Link } from '../../../types'; +import type { Notification, PullRequest } from '../../../typesGitHub'; +import { + getLatestReviewForReviewers, + parseLinkedIssuesFromPr, + pullRequestHandler, +} from './pullRequest'; + +describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { + let mockNotification: Notification; + + beforeEach(() => { + mockNotification = partialMockNotification({ + title: 'This is a mock pull request', + type: 'PullRequest', + url: 'https://api.github.com/repos/gitify-app/notifications-test/pulls/1' as Link, + latest_comment_url: + 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, + }); + }); + + describe('enrich', () => { + const mockAuthor = partialMockUser('some-author'); + const mockCommenter = partialMockUser('some-commenter'); + + it('closed pull request state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'closed', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'closed', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + it('draft pull request state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: true, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'draft', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + it('merged pull request state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: true, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'merged', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + it('open pull request state', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + it('avoid fetching comments if latest_comment_url and url are the same', async () => { + mockNotification.subject.latest_comment_url = + mockNotification.subject.url; + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + it('handle pull request without latest_comment_url', async () => { + mockNotification.subject.latest_comment_url = null; + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + + describe('Pull Requests With Labels', () => { + it('with labels', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [{ name: 'enhancement' }], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: ['enhancement'], + linkedIssues: [], + }); + }); + + it('handle null labels', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: null, + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await pullRequestHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + number: 123, + state: 'open', + user: { + login: mockCommenter.login, + html_url: mockCommenter.html_url, + avatar_url: mockCommenter.avatar_url, + type: mockCommenter.type, + }, + reviews: null, + labels: [], + linkedIssues: [], + }); + }); + }); + + describe('Pull Request With Linked Issues', () => { + it('returns empty if no pr body', () => { + const mockPr = { + user: { + type: 'User', + }, + body: null, + } as PullRequest; + + const result = parseLinkedIssuesFromPr(mockPr); + expect(result).toEqual([]); + }); + + it('returns empty if pr from non-user', () => { + const mockPr = { + user: { + type: 'Bot', + }, + body: 'This PR is linked to #1, #2, and #3', + } as PullRequest; + const result = parseLinkedIssuesFromPr(mockPr); + expect(result).toEqual([]); + }); + + it('returns linked issues', () => { + const mockPr = { + user: { + type: 'User', + }, + body: 'This PR is linked to #1, #2, and #3', + } as PullRequest; + const result = parseLinkedIssuesFromPr(mockPr); + expect(result).toEqual(['#1', '#2', '#3']); + }); + }); + + it('early return if pull request state filtered', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + const result = await pullRequestHandler.enrich(mockNotification, { + ...mockSettings, + filterStates: ['closed'], + }); + + expect(result).toEqual(null); + }); + + it('early return if pull request user filtered', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1') + .reply(200, { + number: 123, + state: 'open', + draft: false, + merged: false, + user: mockAuthor, + labels: [], + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/comments/302888448') + .reply(200, { user: mockCommenter }); + + const result = await pullRequestHandler.enrich(mockNotification, { + ...mockSettings, + filterUserTypes: ['Bot'], + }); + + expect(result).toEqual(null); + }); + }); + + it('getIcon', () => { + expect( + pullRequestHandler.getIcon(createSubjectMock({ type: 'PullRequest' })) + .displayName, + ).toBe('GitPullRequestIcon'); + + expect( + pullRequestHandler.getIcon( + createSubjectMock({ + type: 'PullRequest', + state: 'draft', + }), + ).displayName, + ).toBe('GitPullRequestDraftIcon'); + + expect( + pullRequestHandler.getIcon( + createSubjectMock({ + type: 'PullRequest', + state: 'closed', + }), + ).displayName, + ).toBe('GitPullRequestClosedIcon'); + + expect( + pullRequestHandler.getIcon( + createSubjectMock({ + type: 'PullRequest', + state: 'merged', + }), + ).displayName, + ).toBe('GitMergeIcon'); + }); + + describe('Pull Request Reviews - Latest Reviews By Reviewer', () => { + it('returns latest review state per reviewer', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, [ + { + user: { + login: 'reviewer-1', + }, + state: 'REQUESTED_CHANGES', + }, + { + user: { + login: 'reviewer-2', + }, + state: 'COMMENTED', + }, + { + user: { + login: 'reviewer-1', + }, + state: 'APPROVED', + }, + { + user: { + login: 'reviewer-3', + }, + state: 'APPROVED', + }, + ]); + + const result = await getLatestReviewForReviewers(mockNotification); + + expect(result).toEqual([ + { state: 'APPROVED', users: ['reviewer-3', 'reviewer-1'] }, + { state: 'COMMENTED', users: ['reviewer-2'] }, + ]); + }); + + it('handles no PR reviews yet', async () => { + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/pulls/1/reviews') + .reply(200, []); + + const result = await getLatestReviewForReviewers(mockNotification); + + expect(result).toBeNull(); + }); + + it('returns null when not a PR notification', async () => { + mockNotification.subject.type = 'Issue'; + + const result = await getLatestReviewForReviewers(mockNotification); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/release.test.ts b/src/renderer/utils/notifications/handlers/release.test.ts new file mode 100644 index 000000000..19fef8bea --- /dev/null +++ b/src/renderer/utils/notifications/handlers/release.test.ts @@ -0,0 +1,72 @@ +import nock from 'nock'; + +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { + partialMockNotification, + partialMockUser, +} from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import type { Link } from '../../../types'; +import { releaseHandler } from './release'; + +describe('renderer/utils/notifications/handlers/release.ts', () => { + describe('enrich', () => { + const mockAuthor = partialMockUser('some-author'); + + it('release notification', async () => { + const mockNotification = partialMockNotification({ + title: 'This is a mock release', + type: 'Release', + url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, + latest_comment_url: + 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, + }); + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/releases/1') + .reply(200, { author: mockAuthor }); + + const result = await releaseHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: null, + user: { + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }, + }); + }); + + it('return early if release state filtered', async () => { + const mockNotification = partialMockNotification({ + title: 'This is a mock release', + type: 'Release', + url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, + latest_comment_url: + 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, + }); + + const result = await releaseHandler.enrich(mockNotification, { + ...mockSettings, + filterStates: ['closed'], + }); + + expect(result).toEqual(null); + }); + }); + + it('getIcon', () => { + expect( + releaseHandler.getIcon( + createSubjectMock({ + type: 'Release', + }), + ).displayName, + ).toBe('TagIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.test.ts b/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.test.ts new file mode 100644 index 000000000..7d1937f56 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.test.ts @@ -0,0 +1,14 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { repositoryDependabotAlertsThreadHandler } from './repositoryDependabotAlertsThread'; + +describe('renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.ts', () => { + it('getIcon', () => { + expect( + repositoryDependabotAlertsThreadHandler.getIcon( + createSubjectMock({ + type: 'RepositoryDependabotAlertsThread', + }), + ).displayName, + ).toBe('AlertIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts b/src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.ts similarity index 100% rename from src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread copy.ts rename to src/renderer/utils/notifications/handlers/repositoryDependabotAlertsThread.ts diff --git a/src/renderer/utils/notifications/handlers/repositoryInvitation.test.ts b/src/renderer/utils/notifications/handlers/repositoryInvitation.test.ts new file mode 100644 index 000000000..30d52ee2b --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryInvitation.test.ts @@ -0,0 +1,14 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { repositoryInvitationHandler } from './repositoryInvitation'; + +describe('renderer/utils/notifications/handlers/repositoryInvitation.ts', () => { + it('getIcon', () => { + expect( + repositoryInvitationHandler.getIcon( + createSubjectMock({ + type: 'RepositoryInvitation', + }), + ).displayName, + ).toBe('MailIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.test.ts b/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.test.ts new file mode 100644 index 000000000..d1a88b395 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.test.ts @@ -0,0 +1,14 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { repositoryVulnerabilityAlertHandler } from './repositoryVulnerabilityAlert'; + +describe('renderer/utils/notifications/handlers/repositoryVulnerabilityAlert.ts', () => { + it('getIcon', () => { + expect( + repositoryVulnerabilityAlertHandler.getIcon( + createSubjectMock({ + type: 'RepositoryVulnerabilityAlert', + }), + ).displayName, + ).toBe('AlertIcon'); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/utils.test.ts b/src/renderer/utils/notifications/handlers/utils.test.ts new file mode 100644 index 000000000..9863cc4a5 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/utils.test.ts @@ -0,0 +1,36 @@ +import { partialMockUser } from '../../../__mocks__/partial-mocks'; +import { getSubjectUser } from './utils'; + +describe('renderer/utils/notifications/handlers/utils.ts', () => { + describe('getSubjectUser', () => { + const mockAuthor = partialMockUser('some-author'); + + it('returns null when all users are null', () => { + const result = getSubjectUser([null, null]); + + expect(result).toBeNull(); + }); + + it('returns first user', () => { + const result = getSubjectUser([mockAuthor, null]); + + expect(result).toEqual({ + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }); + }); + + it('returns second user if first is null', () => { + const result = getSubjectUser([null, mockAuthor]); + + expect(result).toEqual({ + login: mockAuthor.login, + html_url: mockAuthor.html_url, + avatar_url: mockAuthor.avatar_url, + type: mockAuthor.type, + }); + }); + }); +}); diff --git a/src/renderer/utils/notifications/handlers/workflowRun.test.ts b/src/renderer/utils/notifications/handlers/workflowRun.test.ts new file mode 100644 index 000000000..000d4ccb1 --- /dev/null +++ b/src/renderer/utils/notifications/handlers/workflowRun.test.ts @@ -0,0 +1,108 @@ +import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import { mockSettings } from '../../../__mocks__/state-mocks'; +import { getWorkflowRunAttributes, workflowRunHandler } from './workflowRun'; + +describe('renderer/utils/notifications/handlers/workflowRun.ts', () => { + describe('enrich', () => { + it('deploy review workflow run state', async () => { + const mockNotification = partialMockNotification({ + title: 'some-user requested your review to deploy to an environment', + type: 'WorkflowRun', + }); + + const result = await workflowRunHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toEqual({ + state: 'waiting', + user: null, + }); + }); + + it('unknown workflow run state', async () => { + const mockNotification = partialMockNotification({ + title: + 'some-user requested your unknown-state to deploy to an environment', + type: 'WorkflowRun', + }); + + const result = await workflowRunHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toBeNull(); + }); + + it('unhandled workflow run title', async () => { + const mockNotification = partialMockNotification({ + title: 'unhandled workflow run structure', + type: 'WorkflowRun', + }); + + const result = await workflowRunHandler.enrich( + mockNotification, + mockSettings, + ); + + expect(result).toBeNull(); + }); + }); + + it('getIcon', () => { + expect( + workflowRunHandler.getIcon( + createSubjectMock({ + type: 'WorkflowRun', + }), + ).displayName, + ).toBe('RocketIcon'); + }); + + describe('getWorkflowRunAttributes', () => { + it('deploy review workflow run state', async () => { + const mockNotification = partialMockNotification({ + title: 'some-user requested your review to deploy to an environment', + type: 'WorkflowRun', + }); + + const result = getWorkflowRunAttributes(mockNotification); + + expect(result).toEqual({ + status: 'waiting', + statusDisplayName: 'review', + user: 'some-user', + }); + }); + + it('unknown workflow run state', async () => { + const mockNotification = partialMockNotification({ + title: + 'some-user requested your unknown-state to deploy to an environment', + type: 'WorkflowRun', + }); + + const result = getWorkflowRunAttributes(mockNotification); + + expect(result).toEqual({ + status: null, + statusDisplayName: 'unknown-state', + user: 'some-user', + }); + }); + + it('unhandled workflow run title', async () => { + const mockNotification = partialMockNotification({ + title: 'unhandled workflow run structure', + type: 'WorkflowRun', + }); + + const result = getWorkflowRunAttributes(mockNotification); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 9c789c5f6..6fb9cd7be 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -1,5 +1,12 @@ +import nock from 'nock'; + +import * as logger from '../../../shared/logger'; import { mockSingleAccountNotifications } from '../../__mocks__/notifications-mocks'; -import { getNotificationCount } from './notifications'; +import { partialMockNotification } from '../../__mocks__/partial-mocks'; +import { mockSettings } from '../../__mocks__/state-mocks'; +import type { Repository } from '../../typesGitHub'; +import { enrichNotification, getNotificationCount } from './notifications'; +import { Link } from '../../types'; describe('renderer/utils/notifications/notifications.ts', () => { afterEach(() => { @@ -11,4 +18,32 @@ describe('renderer/utils/notifications/notifications.ts', () => { expect(result).toBe(1); }); + + it('enrichNotification - catches error and logs message', async () => { + const logErrorSpy = jest.spyOn(logger, 'logError').mockImplementation(); + + const mockError = new Error('Test error'); + const mockNotification = partialMockNotification({ + title: 'This issue will throw an error', + type: 'Issue', + url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, + }); + const mockRepository = { + full_name: 'gitify-app/notifications-test', + } as Repository; + mockNotification.repository = mockRepository; + + nock('https://api.github.com') + .get('/repos/gitify-app/notifications-test/issues/1') + .replyWithError(mockError); + + await enrichNotification(mockNotification, mockSettings); + + expect(logErrorSpy).toHaveBeenCalledWith( + 'enrichNotification', + 'failed to fetch details for notification for', + mockError, + mockNotification, + ); + }); }); diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index 0ab99bad9..45695358c 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -105,39 +105,40 @@ export async function enrichNotifications( const enrichedNotifications = await Promise.all( notifications.map(async (notification: Notification) => { - let additionalSubjectDetails: GitifySubject = {}; - - try { - const handler = createNotificationHandler(notification); - if (handler) { - additionalSubjectDetails = await handler.enrich( - notification, - settings, - ); - } - } catch (err) { - logError( - 'enrichNotifications', - 'failed to enrich notification details for', - err, - notification, - ); - - logWarn( - 'enrichNotifications', - 'Continuing with base notification details', - ); - } - - return { - ...notification, - subject: { - ...notification.subject, - ...additionalSubjectDetails, - }, - }; + return enrichNotification(notification, settings); }), ); return enrichedNotifications; } + +export async function enrichNotification( + notification: Notification, + settings: SettingsState, +) { + let additionalSubjectDetails: GitifySubject = {}; + + try { + const handler = createNotificationHandler(notification); + if (handler) { + additionalSubjectDetails = await handler.enrich(notification, settings); + } + } catch (err) { + logError( + 'enrichNotification', + 'failed to enrich notification details for', + err, + notification, + ); + + logWarn('enrichNotification', 'Continuing with base notification details'); + } + + return { + ...notification, + subject: { + ...notification.subject, + ...additionalSubjectDetails, + }, + }; +} diff --git a/src/renderer/utils/subject.test.ts b/src/renderer/utils/subject.test.ts deleted file mode 100644 index 21c0b7b14..000000000 --- a/src/renderer/utils/subject.test.ts +++ /dev/null @@ -1,1630 +0,0 @@ -// import axios from 'axios'; -// import nock from 'nock'; - -// import { -// partialMockNotification, -// partialMockUser, -// } from '../__mocks__/partial-mocks'; -// import type { Link } from '../types'; -// import type { -// Discussion, -// DiscussionAuthor, -// DiscussionStateType, -// Notification, -// PullRequest, -// Repository, -// } from '../typesGitHub'; -// import { -// getGitifySubjectDetails, -// } from './subject'; - -// const mockAuthor = partialMockUser('some-author'); -// const mockCommenter = partialMockUser('some-commenter'); -// const mockDiscussionAuthor: DiscussionAuthor = { -// login: 'discussion-author', -// url: 'https://github.com/discussion-author' as Link, -// avatar_url: 'https://avatars.githubusercontent.com/u/123456789?v=4' as Link, -// type: 'User', -// }; - -// import * as logger from '../../shared/logger'; -// import { mockSettings } from '../__mocks__/state-mocks'; - -// describe('renderer/utils/subject.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('getGitifySubjectDetails', () => { -// describe('CheckSuites - GitHub Actions', () => { -// it('cancelled check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run cancelled for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'cancelled', -// user: null, -// }); -// }); - -// it('failed check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run failed for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'failure', -// user: null, -// }); -// }); - -// it('failed at startup check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run failed at startup for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'failure', -// user: null, -// }); -// }); - -// it('multiple attempts failed check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run, Attempt #3 failed for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'failure', -// user: null, -// }); -// }); - -// it('skipped check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run skipped for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'skipped', -// user: null, -// }); -// }); - -// it('successful check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run succeeded for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'success', -// user: null, -// }); -// }); - -// it('unknown check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run unknown-status for main branch', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toBeNull(); -// }); - -// it('unhandled check suite title', async () => { -// const mockNotification = partialMockNotification({ -// title: 'A title that is not in the structure we expect', -// type: 'CheckSuite', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toBeNull(); -// }); -// }); -// describe('Commits', () => { -// it('get commit commenter', async () => { -// const mockNotification = partialMockNotification({ -// title: 'This is a commit with comments', -// type: 'Commit', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, -// latest_comment_url: -// 'https://api.github.com/repos/gitify-app/notifications-test/comments/141012658' as Link, -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', -// ) -// .reply(200, { author: mockAuthor }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/comments/141012658') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: null, -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// }); -// }); - -// it('get commit without commenter', async () => { -// const mockNotification = partialMockNotification({ -// title: 'This is a commit with comments', -// type: 'Commit', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, -// latest_comment_url: null, -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8', -// ) -// .reply(200, { author: mockAuthor }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: null, -// user: { -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }, -// }); -// }); - -// it('return early if commit state filtered', async () => { -// const mockNotification = partialMockNotification({ -// title: 'This is a commit with comments', -// type: 'Commit', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8' as Link, -// latest_comment_url: null, -// }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterStates: ['closed'], -// }); - -// expect(result).toEqual(null); -// }); -// }); - -// describe('Discussions', () => { -// const partialRepository: Partial = { -// full_name: 'gitify-app/notifications-test', -// }; - -// const mockNotification = partialMockNotification({ -// title: 'This is a mock discussion', -// type: 'Discussion', -// }); -// mockNotification.updated_at = '2024-01-01T00:00:00Z'; -// mockNotification.repository = { -// ...(partialRepository as Repository), -// }; - -// it('answered discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode(null, true)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'ANSWERED', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('duplicate discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode('DUPLICATE', false)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'DUPLICATE', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('open discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode(null, false)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'OPEN', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('outdated discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode('OUTDATED', false)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'OUTDATED', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('reopened discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode('REOPENED', false)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'REOPENED', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('resolved discussion state', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode('RESOLVED', true)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'RESOLVED', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: [], -// }); -// }); - -// it('discussion with labels', async () => { -// const mockDiscussion = mockDiscussionNode(null, true); -// mockDiscussion.labels = { -// nodes: [ -// { -// name: 'enhancement', -// }, -// ], -// }; -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussion], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'ANSWERED', -// user: { -// login: mockDiscussionAuthor.login, -// html_url: mockDiscussionAuthor.url, -// avatar_url: mockDiscussionAuthor.avatar_url, -// type: mockDiscussionAuthor.type, -// }, -// comments: 0, -// labels: ['enhancement'], -// }); -// }); - -// it('early return if discussion state filtered', async () => { -// nock('https://api.github.com') -// .post('/graphql') -// .reply(200, { -// data: { -// search: { -// nodes: [mockDiscussionNode(null, false)], -// }, -// }, -// }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterStates: ['closed'], -// }); - -// expect(result).toEqual(null); -// }); -// }); - -// describe('Issues', () => { -// let mockNotification: Notification; - -// beforeEach(() => { -// mockNotification = partialMockNotification({ -// title: 'This is a mock issue', -// type: 'Issue', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, -// latest_comment_url: -// 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, -// }); -// }); - -// it('open issue state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); - -// it('closed issue state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'closed', -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'closed', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); - -// it('completed issue state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'closed', -// state_reason: 'completed', -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'completed', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); - -// it('not_planned issue state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// state_reason: 'not_planned', -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'not_planned', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); - -// it('reopened issue state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// state_reason: 'reopened', -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'reopened', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); - -// it('handle issues without latest_comment_url', async () => { -// mockNotification.subject.latest_comment_url = null; - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }, -// labels: [], -// }); -// }); - -// describe('Issue With Labels', () => { -// it('with labels', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// user: mockAuthor, -// labels: [{ name: 'enhancement' }], -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/issues/comments/302888448', -// ) -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: ['enhancement'], -// }); -// }); - -// it('handle null labels', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// user: mockAuthor, -// labels: null, -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/issues/comments/302888448', -// ) -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// labels: [], -// }); -// }); -// }); - -// it('early return if issue state filtered out', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .reply(200, { -// number: 123, -// state: 'open', -// user: mockAuthor, -// labels: [], -// }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterStates: ['closed'], -// }); - -// expect(result).toEqual(null); -// }); -// }); - -// describe('Pull Requests', () => { -// let mockNotification: Notification; - -// beforeEach(() => { -// mockNotification = partialMockNotification({ -// title: 'This is a mock pull request', -// type: 'PullRequest', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/pulls/1' as Link, -// latest_comment_url: -// 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, -// }); -// }); - -// it('closed pull request state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'closed', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'closed', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// it('draft pull request state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: true, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'draft', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// it('merged pull request state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: true, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'merged', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// it('open pull request state', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// it('avoid fetching comments if latest_comment_url and url are the same', async () => { -// mockNotification.subject.latest_comment_url = -// mockNotification.subject.url; - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// it('handle pull request without latest_comment_url', async () => { -// mockNotification.subject.latest_comment_url = null; - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); - -// describe('Pull Request Reviews - Latest Reviews By Reviewer', () => { -// it('returns latest review state per reviewer', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, [ -// { -// user: { -// login: 'reviewer-1', -// }, -// state: 'REQUESTED_CHANGES', -// }, -// { -// user: { -// login: 'reviewer-2', -// }, -// state: 'COMMENTED', -// }, -// { -// user: { -// login: 'reviewer-1', -// }, -// state: 'APPROVED', -// }, -// { -// user: { -// login: 'reviewer-3', -// }, -// state: 'APPROVED', -// }, -// ]); - -// const result = await getLatestReviewForReviewers(mockNotification); - -// expect(result).toEqual([ -// { state: 'APPROVED', users: ['reviewer-3', 'reviewer-1'] }, -// { state: 'COMMENTED', users: ['reviewer-2'] }, -// ]); -// }); - -// it('handles no PR reviews yet', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getLatestReviewForReviewers(mockNotification); - -// expect(result).toBeNull(); -// }); - -// it('returns null when not a PR notification', async () => { -// mockNotification.subject.type = 'Issue'; - -// const result = await getLatestReviewForReviewers(mockNotification); - -// expect(result).toBeNull(); -// }); -// }); - -// describe('Pull Requests With Labels', () => { -// it('with labels', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [{ name: 'enhancement' }], -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/issues/comments/302888448', -// ) -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: ['enhancement'], -// linkedIssues: [], -// }); -// }); - -// it('handle null labels', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: null, -// }); - -// nock('https://api.github.com') -// .get( -// '/repos/gitify-app/notifications-test/issues/comments/302888448', -// ) -// .reply(200, { user: mockCommenter }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1/reviews') -// .reply(200, []); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// number: 123, -// state: 'open', -// user: { -// login: mockCommenter.login, -// html_url: mockCommenter.html_url, -// avatar_url: mockCommenter.avatar_url, -// type: mockCommenter.type, -// }, -// reviews: null, -// labels: [], -// linkedIssues: [], -// }); -// }); -// }); - -// describe('Pull Request With Linked Issues', () => { -// it('returns empty if no pr body', () => { -// const mockPr = { -// user: { -// type: 'User', -// }, -// body: null, -// } as PullRequest; - -// const result = parseLinkedIssuesFromPr(mockPr); -// expect(result).toEqual([]); -// }); - -// it('returns empty if pr from non-user', () => { -// const mockPr = { -// user: { -// type: 'Bot', -// }, -// body: 'This PR is linked to #1, #2, and #3', -// } as PullRequest; -// const result = parseLinkedIssuesFromPr(mockPr); -// expect(result).toEqual([]); -// }); - -// it('returns linked issues', () => { -// const mockPr = { -// user: { -// type: 'User', -// }, -// body: 'This PR is linked to #1, #2, and #3', -// } as PullRequest; -// const result = parseLinkedIssuesFromPr(mockPr); -// expect(result).toEqual(['#1', '#2', '#3']); -// }); -// }); - -// it('early return if pull request state filtered', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterStates: ['closed'], -// }); - -// expect(result).toEqual(null); -// }); - -// it('early return if pull request user filtered', async () => { -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/pulls/1') -// .reply(200, { -// number: 123, -// state: 'open', -// draft: false, -// merged: false, -// user: mockAuthor, -// labels: [], -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/comments/302888448') -// .reply(200, { user: mockCommenter }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterUserTypes: ['Bot'], -// }); - -// expect(result).toEqual(null); -// }); -// }); - -// describe('Releases', () => { -// it('release notification', async () => { -// const mockNotification = partialMockNotification({ -// title: 'This is a mock release', -// type: 'Release', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, -// latest_comment_url: -// 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, -// }); - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/releases/1') -// .reply(200, { author: mockAuthor }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: null, -// user: { -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }, -// }); -// }); - -// it('return early if release state filtered', async () => { -// const mockNotification = partialMockNotification({ -// title: 'This is a mock release', -// type: 'Release', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, -// latest_comment_url: -// 'https://api.github.com/repos/gitify-app/notifications-test/releases/1' as Link, -// }); - -// const result = await getGitifySubjectDetails(mockNotification, { -// ...mockSettings, -// filterStates: ['closed'], -// }); - -// expect(result).toEqual(null); -// }); -// }); - -// describe('WorkflowRuns - GitHub Actions', () => { -// it('deploy review workflow run state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'some-user requested your review to deploy to an environment', -// type: 'WorkflowRun', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toEqual({ -// state: 'waiting', -// user: null, -// }); -// }); - -// it('unknown workflow run state', async () => { -// const mockNotification = partialMockNotification({ -// title: -// 'some-user requested your unknown-state to deploy to an environment', -// type: 'WorkflowRun', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toBeNull(); -// }); - -// it('unhandled workflow run title', async () => { -// const mockNotification = partialMockNotification({ -// title: 'unhandled workflow run structure', -// type: 'WorkflowRun', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toBeNull(); -// }); -// }); - -// describe('Default', () => { -// it('unhandled subject details', async () => { -// const mockNotification = partialMockNotification({ -// title: -// 'There is no special subject handling for this notification type', -// type: 'RepositoryInvitation', -// }); - -// const result = await getGitifySubjectDetails( -// mockNotification, -// mockSettings, -// ); - -// expect(result).toBeNull(); -// }); -// }); - -// describe('Error', () => { -// it('catches error and logs message', async () => { -// const logErrorSpy = jest.spyOn(logger, 'logError').mockImplementation(); - -// const mockError = new Error('Test error'); -// const mockNotification = partialMockNotification({ -// title: 'This issue will throw an error', -// type: 'Issue', -// url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, -// }); -// const mockRepository = { -// full_name: 'gitify-app/notifications-test', -// } as Repository; -// mockNotification.repository = mockRepository; - -// nock('https://api.github.com') -// .get('/repos/gitify-app/notifications-test/issues/1') -// .replyWithError(mockError); - -// await getGitifySubjectDetails(mockNotification, mockSettings); - -// expect(logErrorSpy).toHaveBeenCalledWith( -// 'getGitifySubjectDetails', -// 'failed to fetch details for notification for', -// mockError, -// mockNotification, -// ); -// }); -// }); -// }); - -// describe('getCheckSuiteState', () => { -// it('cancelled check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run cancelled for feature/foo branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: null, -// status: 'cancelled', -// statusDisplayName: 'cancelled', -// branchName: 'feature/foo', -// }); -// }); - -// it('failed check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run failed for main branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: null, -// status: 'failure', -// statusDisplayName: 'failed', -// branchName: 'main', -// }); -// }); - -// it('multiple attempts failed check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run, Attempt #3 failed for main branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: 3, -// status: 'failure', -// statusDisplayName: 'failed', -// branchName: 'main', -// }); -// }); - -// it('skipped check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run skipped for main branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: null, -// status: 'skipped', -// statusDisplayName: 'skipped', -// branchName: 'main', -// }); -// }); - -// it('successful check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run succeeded for main branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: null, -// status: 'success', -// statusDisplayName: 'succeeded', -// branchName: 'main', -// }); -// }); - -// it('unknown check suite state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'Demo workflow run unknown-status for main branch', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toEqual({ -// workflowName: 'Demo', -// attemptNumber: null, -// status: null, -// statusDisplayName: 'unknown-status', -// branchName: 'main', -// }); -// }); - -// it('unhandled check suite title', async () => { -// const mockNotification = partialMockNotification({ -// title: 'A title that is not in the structure we expect', -// type: 'CheckSuite', -// }); - -// const result = getCheckSuiteAttributes(mockNotification); - -// expect(result).toBeNull(); -// }); -// }); - -// describe('getWorkflowRunState', () => { -// it('deploy review workflow run state', async () => { -// const mockNotification = partialMockNotification({ -// title: 'some-user requested your review to deploy to an environment', -// type: 'WorkflowRun', -// }); - -// const result = getWorkflowRunAttributes(mockNotification); - -// expect(result).toEqual({ -// status: 'waiting', -// statusDisplayName: 'review', -// user: 'some-user', -// }); -// }); - -// it('unknown workflow run state', async () => { -// const mockNotification = partialMockNotification({ -// title: -// 'some-user requested your unknown-state to deploy to an environment', -// type: 'WorkflowRun', -// }); - -// const result = getWorkflowRunAttributes(mockNotification); - -// expect(result).toEqual({ -// status: null, -// statusDisplayName: 'unknown-state', -// user: 'some-user', -// }); -// }); - -// it('unhandled workflow run title', async () => { -// const mockNotification = partialMockNotification({ -// title: 'unhandled workflow run structure', -// type: 'WorkflowRun', -// }); - -// const result = getWorkflowRunAttributes(mockNotification); - -// expect(result).toBeNull(); -// }); -// }); - -// describe('getSubjectUser', () => { -// it('returns null when all users are null', () => { -// const result = getSubjectUser([null, null]); - -// expect(result).toBeNull(); -// }); - -// it('returns first user', () => { -// const result = getSubjectUser([mockAuthor, null]); - -// expect(result).toEqual({ -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }); -// }); - -// it('returns second user if first is null', () => { -// const result = getSubjectUser([null, mockAuthor]); - -// expect(result).toEqual({ -// login: mockAuthor.login, -// html_url: mockAuthor.html_url, -// avatar_url: mockAuthor.avatar_url, -// type: mockAuthor.type, -// }); -// }); -// }); -// }); - -// function mockDiscussionNode( -// state: DiscussionStateType, -// isAnswered: boolean, -// ): Discussion { -// return { -// number: 123, -// title: 'This is a mock discussion', -// url: 'https://github.com/gitify-app/notifications-test/discussions/1' as Link, -// stateReason: state, -// isAnswered: isAnswered, -// author: mockDiscussionAuthor, -// comments: { -// nodes: [], -// totalCount: 0, -// }, -// labels: null, -// }; -// } From a275d05446daa728db3709a9c03f9836e022646d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 16:24:51 -0400 Subject: [PATCH 03/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/utils/notifications/notifications.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 6fb9cd7be..7408ee1ea 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -4,9 +4,9 @@ import * as logger from '../../../shared/logger'; import { mockSingleAccountNotifications } from '../../__mocks__/notifications-mocks'; import { partialMockNotification } from '../../__mocks__/partial-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; +import type { Link } from '../../types'; import type { Repository } from '../../typesGitHub'; import { enrichNotification, getNotificationCount } from './notifications'; -import { Link } from '../../types'; describe('renderer/utils/notifications/notifications.ts', () => { afterEach(() => { From cd715987ad04eb8a601a61be2677e42eb20e57bf Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 17:37:52 -0400 Subject: [PATCH 04/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/utils/notifications/notifications.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 7408ee1ea..615f54ea4 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -41,7 +41,7 @@ describe('renderer/utils/notifications/notifications.ts', () => { expect(logErrorSpy).toHaveBeenCalledWith( 'enrichNotification', - 'failed to fetch details for notification for', + 'failed to enrich notification details for', mockError, mockNotification, ); From aa3eae9a4574a2538099671a776efdc47519ee94 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 17:47:37 -0400 Subject: [PATCH 05/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/__helpers__/jest.setup.ts | 11 +++++--- .../SettingsFooter.test.tsx.snap | 26 ------------------- src/renderer/utils/auth/utils.test.ts | 5 ---- 3 files changed, 7 insertions(+), 35 deletions(-) diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 27fade6d4..bc0e7e88a 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -3,6 +3,13 @@ import { TextDecoder, TextEncoder } from 'node:util'; import axios from 'axios'; +/** + * 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'; + + /** * Prevent the following errors with jest: * - ReferenceError: TextEncoder is not defined @@ -37,7 +44,3 @@ global.ResizeObserver = class { unobserve() {} disconnect() {} }; - -// 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'; diff --git a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap index e42608a42..36e0732a8 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap @@ -51,29 +51,3 @@ exports[`renderer/components/settings/SettingsFooter.tsx app version should show `; - -exports[`renderer/components/settings/SettingsFooter.tsx should open release notes 1`] = ` - -`; diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index ef104bd3b..a3fe9854e 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,7 +1,6 @@ import { ipcRenderer } from 'electron'; import type { AxiosPromise, AxiosResponse } from 'axios'; -import axios from 'axios'; import nock from 'nock'; import { @@ -169,10 +168,6 @@ describe('renderer/utils/auth/utils.ts', () => { mockAuthState = { accounts: [], }; - - // 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('should add GitHub Cloud account', () => { From e91c7ab6b1a54798e827c473d0433f2b7069751b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 18:05:54 -0400 Subject: [PATCH 06/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/__helpers__/jest.setup.ts | 1 - .../notifications/handlers/index.test.ts | 51 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/renderer/utils/notifications/handlers/index.test.ts diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index bc0e7e88a..645a87a34 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -9,7 +9,6 @@ import axios from 'axios'; */ axios.defaults.adapter = 'http'; - /** * Prevent the following errors with jest: * - ReferenceError: TextEncoder is not defined diff --git a/src/renderer/utils/notifications/handlers/index.test.ts b/src/renderer/utils/notifications/handlers/index.test.ts new file mode 100644 index 000000000..7964c41fd --- /dev/null +++ b/src/renderer/utils/notifications/handlers/index.test.ts @@ -0,0 +1,51 @@ +import { partialMockNotification } from '../../../__mocks__/partial-mocks'; +import type { SubjectType } from '../../../typesGitHub'; +import { checkSuiteHandler } from './checkSuite'; +import { commitHandler } from './commit'; +import { defaultHandler } from './default'; +import { discussionHandler } from './discussion'; +import { createNotificationHandler } from './index'; +import { issueHandler } from './issue'; +import { pullRequestHandler } from './pullRequest'; +import { releaseHandler } from './release'; +import { repositoryDependabotAlertsThreadHandler } from './repositoryDependabotAlertsThread'; +import { repositoryInvitationHandler } from './repositoryInvitation'; +import { repositoryVulnerabilityAlertHandler } from './repositoryVulnerabilityAlert'; +import { workflowRunHandler } from './workflowRun'; + +describe('renderer/utils/notifications/handlers/index.ts', () => { + describe('createNotificationHandler', () => { + const cases: Array<[SubjectType, object]> = [ + ['CheckSuite', checkSuiteHandler], + ['Commit', commitHandler], + ['Discussion', discussionHandler], + ['Issue', issueHandler], + ['PullRequest', pullRequestHandler], + ['Release', releaseHandler], + [ + 'RepositoryDependabotAlertsThread', + repositoryDependabotAlertsThreadHandler, + ], + ['RepositoryInvitation', repositoryInvitationHandler], + ['RepositoryVulnerabilityAlert', repositoryVulnerabilityAlertHandler], + ['WorkflowRun', workflowRunHandler], + ]; + + it.each(cases)( + 'returns expected handler instance for %s', + (type, expected) => { + const notification = partialMockNotification({ type }); + const handler = createNotificationHandler(notification); + expect(handler).toBe(expected); + }, + ); + + it('falls back to default handler for unknown type', () => { + const notification = partialMockNotification({ + type: 'SomeFutureType' as SubjectType, + }); + const handler = createNotificationHandler(notification); + expect(handler).toBe(defaultHandler); + }); + }); +}); From f28f6fd86576b2f0e69d49b247fb56f00c6cf69b Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 18:08:51 -0400 Subject: [PATCH 07/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/utils/notifications/handlers/types.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/renderer/utils/notifications/handlers/types.ts b/src/renderer/utils/notifications/handlers/types.ts index 2c1bf3536..d80368cf6 100644 --- a/src/renderer/utils/notifications/handlers/types.ts +++ b/src/renderer/utils/notifications/handlers/types.ts @@ -23,10 +23,4 @@ export interface NotificationTypeHandler { /** Return an icon component for this notification type. */ getIcon(subject: Subject): FC | null; - - /** Build a URL for this notification type (subject urls / comments resolved separately upstream). */ - buildUrl?(notification: Notification): Promise | Link; - - /** Optional color helper if we later move color logic into handlers. */ - getIconColor?(subject: Subject): string | undefined; } From 121305ad40b20a8050375ecce722c7a00a274c0c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Mon, 18 Aug 2025 19:29:05 -0400 Subject: [PATCH 08/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/utils/notifications/handlers/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/notifications/handlers/types.ts b/src/renderer/utils/notifications/handlers/types.ts index d80368cf6..7f377298b 100644 --- a/src/renderer/utils/notifications/handlers/types.ts +++ b/src/renderer/utils/notifications/handlers/types.ts @@ -2,7 +2,7 @@ import type { FC } from 'react'; import type { OcticonProps } from '@primer/octicons-react'; -import type { Link, SettingsState } from '../../../types'; +import type { SettingsState } from '../../../types'; import type { GitifySubject, Notification, From c3783e84e17dc23b3eff2c4b28cd3d9698282b7a Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 19 Aug 2025 07:21:16 -0400 Subject: [PATCH 09/11] refactor: notification handlers Signed-off-by: Adam Setch --- src/renderer/__helpers__/jest.setup.ts | 8 -------- src/renderer/utils/auth/utils.test.ts | 5 +++++ src/renderer/utils/notifications/handlers/commit.test.ts | 7 +++++++ .../utils/notifications/handlers/discussion.test.ts | 7 +++++++ src/renderer/utils/notifications/handlers/index.ts | 2 +- src/renderer/utils/notifications/handlers/issue.test.ts | 5 +++++ .../utils/notifications/handlers/pullRequest.test.ts | 7 +++++++ src/renderer/utils/notifications/handlers/release.test.ts | 7 +++++++ src/renderer/utils/notifications/notifications.test.ts | 7 +++++++ src/renderer/utils/notifications/notifications.ts | 4 +--- 10 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 645a87a34..1f8c2ac4f 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -1,14 +1,6 @@ import '@testing-library/jest-dom'; import { TextDecoder, TextEncoder } from 'node:util'; -import axios from 'axios'; - -/** - * 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'; - /** * Prevent the following errors with jest: * - ReferenceError: TextEncoder is not defined diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index a3fe9854e..ef104bd3b 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron'; import type { AxiosPromise, AxiosResponse } from 'axios'; +import axios from 'axios'; import nock from 'nock'; import { @@ -168,6 +169,10 @@ describe('renderer/utils/auth/utils.ts', () => { mockAuthState = { accounts: [], }; + + // 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('should add GitHub Cloud account', () => { diff --git a/src/renderer/utils/notifications/handlers/commit.test.ts b/src/renderer/utils/notifications/handlers/commit.test.ts index a99a60a6a..854cb579c 100644 --- a/src/renderer/utils/notifications/handlers/commit.test.ts +++ b/src/renderer/utils/notifications/handlers/commit.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; @@ -14,6 +15,12 @@ describe('renderer/utils/notifications/handlers/commit.ts', () => { const mockAuthor = partialMockUser('some-author'); const mockCommenter = partialMockUser('some-commenter'); + 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'; + }); + it('get commit commenter', async () => { const mockNotification = partialMockNotification({ title: 'This is a commit with comments', diff --git a/src/renderer/utils/notifications/handlers/discussion.test.ts b/src/renderer/utils/notifications/handlers/discussion.test.ts index 712616802..5f03ddb93 100644 --- a/src/renderer/utils/notifications/handlers/discussion.test.ts +++ b/src/renderer/utils/notifications/handlers/discussion.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; @@ -34,6 +35,12 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { ...(partialRepository as Repository), }; + 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'; + }); + it('answered discussion state', async () => { nock('https://api.github.com') .post('/graphql') diff --git a/src/renderer/utils/notifications/handlers/index.ts b/src/renderer/utils/notifications/handlers/index.ts index 579a67495..b6e5d539f 100644 --- a/src/renderer/utils/notifications/handlers/index.ts +++ b/src/renderer/utils/notifications/handlers/index.ts @@ -14,7 +14,7 @@ import { workflowRunHandler } from './workflowRun'; export function createNotificationHandler( notification: Notification, -): NotificationTypeHandler | null { +): NotificationTypeHandler { switch (notification.subject.type) { case 'CheckSuite': return checkSuiteHandler; diff --git a/src/renderer/utils/notifications/handlers/issue.test.ts b/src/renderer/utils/notifications/handlers/issue.test.ts index c7ae54bda..cf437b490 100644 --- a/src/renderer/utils/notifications/handlers/issue.test.ts +++ b/src/renderer/utils/notifications/handlers/issue.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; @@ -25,6 +26,10 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { latest_comment_url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448' as Link, }); + + // 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'; }); it('open issue state', async () => { diff --git a/src/renderer/utils/notifications/handlers/pullRequest.test.ts b/src/renderer/utils/notifications/handlers/pullRequest.test.ts index dfd521c86..6c0a440e2 100644 --- a/src/renderer/utils/notifications/handlers/pullRequest.test.ts +++ b/src/renderer/utils/notifications/handlers/pullRequest.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; @@ -31,6 +32,12 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { const mockAuthor = partialMockUser('some-author'); const mockCommenter = partialMockUser('some-commenter'); + 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'; + }); + it('closed pull request state', async () => { nock('https://api.github.com') .get('/repos/gitify-app/notifications-test/pulls/1') diff --git a/src/renderer/utils/notifications/handlers/release.test.ts b/src/renderer/utils/notifications/handlers/release.test.ts index 19fef8bea..fb34d840c 100644 --- a/src/renderer/utils/notifications/handlers/release.test.ts +++ b/src/renderer/utils/notifications/handlers/release.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import { createSubjectMock } from '../../../__mocks__/notifications-mocks'; @@ -13,6 +14,12 @@ describe('renderer/utils/notifications/handlers/release.ts', () => { describe('enrich', () => { const mockAuthor = partialMockUser('some-author'); + 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'; + }); + it('release notification', async () => { const mockNotification = partialMockNotification({ title: 'This is a mock release', diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 615f54ea4..e326caf81 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import nock from 'nock'; import * as logger from '../../../shared/logger'; @@ -9,6 +10,12 @@ import type { Repository } from '../../typesGitHub'; import { enrichNotification, getNotificationCount } from './notifications'; describe('renderer/utils/notifications/notifications.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'; + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index 45695358c..767e02aed 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -120,9 +120,7 @@ export async function enrichNotification( try { const handler = createNotificationHandler(notification); - if (handler) { - additionalSubjectDetails = await handler.enrich(notification, settings); - } + additionalSubjectDetails = await handler.enrich(notification, settings); } catch (err) { logError( 'enrichNotification', From 1a24d890e47f75742febe36548f17424fc7d403c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 19 Aug 2025 07:40:49 -0400 Subject: [PATCH 10/11] fix sonar issues Signed-off-by: Adam Setch --- .../notifications/handlers/checkSuite.ts | 29 +++++++++---------- .../notifications/handlers/workflowRun.ts | 22 +++++++------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/renderer/utils/notifications/handlers/checkSuite.ts b/src/renderer/utils/notifications/handlers/checkSuite.ts index da3c7809e..1f129b480 100644 --- a/src/renderer/utils/notifications/handlers/checkSuite.ts +++ b/src/renderer/utils/notifications/handlers/checkSuite.ts @@ -63,26 +63,25 @@ export const checkSuiteHandler = new CheckSuiteHandler(); export function getCheckSuiteAttributes( notification: Notification, ): CheckSuiteAttributes | null { - const regexPattern = + const regex = /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; - const matches = regexPattern.exec(notification.subject.title); + const match = regex.exec(notification.subject.title); - if (matches) { - const { groups } = matches; - - return { - workflowName: groups.workflowName, - attemptNumber: groups.attemptNumber - ? Number.parseInt(groups.attemptNumber) - : null, - status: getCheckSuiteStatus(groups.statusDisplayName), - statusDisplayName: groups.statusDisplayName, - branchName: groups.branchName, - }; + if (!match?.groups) { + return null; } - return null; + const { workflowName, attemptNumber, statusDisplayName, branchName } = + match.groups; + + return { + workflowName, + attemptNumber: attemptNumber ? Number.parseInt(attemptNumber) : null, + status: getCheckSuiteStatus(statusDisplayName), + statusDisplayName, + branchName, + }; } function getCheckSuiteStatus(statusDisplayName: string): CheckSuiteStatus { diff --git a/src/renderer/utils/notifications/handlers/workflowRun.ts b/src/renderer/utils/notifications/handlers/workflowRun.ts index 9bd35f739..9dbd4a493 100644 --- a/src/renderer/utils/notifications/handlers/workflowRun.ts +++ b/src/renderer/utils/notifications/handlers/workflowRun.ts @@ -46,22 +46,22 @@ export const workflowRunHandler = new WorkflowRunHandler(); export function getWorkflowRunAttributes( notification: Notification, ): WorkflowRunAttributes | null { - const regexPattern = + const regex = /^(?.*?) requested your (?.*?) to deploy to an environment$/; - const matches = regexPattern.exec(notification.subject.title); + const match = regex.exec(notification.subject.title); - if (matches) { - const { groups } = matches; - - return { - user: groups.user, - status: getWorkflowRunStatus(groups.statusDisplayName), - statusDisplayName: groups.statusDisplayName, - }; + if (!match?.groups) { + return null; } - return null; + const { user, statusDisplayName } = match.groups; + + return { + user: user, + status: getWorkflowRunStatus(statusDisplayName), + statusDisplayName: statusDisplayName, + }; } function getWorkflowRunStatus(statusDisplayName: string): CheckSuiteStatus { From c959873b2e0518b7dac640804bc0a48a17517961 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Tue, 19 Aug 2025 07:41:45 -0400 Subject: [PATCH 11/11] fix sonar issues Signed-off-by: Adam Setch --- src/renderer/utils/notifications/handlers/pullRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/utils/notifications/handlers/pullRequest.ts b/src/renderer/utils/notifications/handlers/pullRequest.ts index d6787a785..baf53b3ee 100644 --- a/src/renderer/utils/notifications/handlers/pullRequest.ts +++ b/src/renderer/utils/notifications/handlers/pullRequest.ts @@ -121,7 +121,7 @@ export async function getLatestReviewForReviewers( // Find the most recent review for each reviewer const latestReviews: PullRequestReview[] = []; - const sortedReviews = prReviews.data.reverse(); + const sortedReviews = prReviews.data.slice().reverse(); for (const prReview of sortedReviews) { const reviewerFound = latestReviews.find( (review) => review.user.login === prReview.user.login,