diff --git a/package.json b/package.json index f3dab9db8..7e18bc957 100644 --- a/package.json +++ b/package.json @@ -149,4 +149,4 @@ "*": "biome check --fix --no-errors-on-unmatched", "*.{js,ts,tsx}": "pnpm test --findRelatedTests --passWithNoTests --updateSnapshot" } -} \ No newline at end of file +} diff --git a/src/renderer/__mocks__/user-mocks.ts b/src/renderer/__mocks__/user-mocks.ts index fe3c6af51..d1435222c 100644 --- a/src/renderer/__mocks__/user-mocks.ts +++ b/src/renderer/__mocks__/user-mocks.ts @@ -1,4 +1,5 @@ import type { GitifyNotificationUser, GitifyUser, Link } from '../types'; +import type { AuthorFieldsFragment } from '../utils/api/graphql/generated/graphql'; import type { RawUser } from '../utils/api/types'; export const mockGitifyUser: GitifyUser = { @@ -19,7 +20,7 @@ export function createPartialMockUser(login: string): RawUser { return mockUser as RawUser; } -export function createMockNotificationUser( +export function createMockGitifyNotificationUser( login: string, ): GitifyNotificationUser { return { @@ -29,3 +30,13 @@ export function createMockNotificationUser( type: 'User', }; } + +/** + * Creates a mock author for use in GraphQL response mocks. + */ +export function createMockGraphQLAuthor(login: string): AuthorFieldsFragment { + return { + ...createMockGitifyNotificationUser(login), + __typename: 'User', + } as AuthorFieldsFragment; +} diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 1f9e9f6d3..340f6c3b4 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -27,6 +27,16 @@ export const Constants = { REFRESH_ACCOUNTS_INTERVAL_MS: 60 * 60 * 1000, // 1 hour + // GraphQL Argument Defaults + GRAPHQL_ARGS: { + FIRST_LABELS: 100, + FIRST_CLOSING_ISSUES: 100, + LAST_COMMENTS: 1, + LAST_THREADED_COMMENTS: 10, + LAST_REPLIES: 10, + LAST_REVIEWS: 100, + }, + // GitHub Docs GITHUB_DOCS: { OAUTH_URL: diff --git a/src/renderer/hooks/useNotifications.test.ts b/src/renderer/hooks/useNotifications.test.ts index e99f84bdc..bb3932843 100644 --- a/src/renderer/hooks/useNotifications.test.ts +++ b/src/renderer/hooks/useNotifications.test.ts @@ -3,12 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import axios, { AxiosError } from 'axios'; import nock from 'nock'; -import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { mockAuth, mockSettings, mockState } from '../__mocks__/state-mocks'; -import { - mockNotificationUser, - mockSingleNotification, -} from '../utils/api/__mocks__/response-mocks'; +import { mockSingleNotification } from '../utils/api/__mocks__/response-mocks'; import { Errors } from '../utils/errors'; import * as logger from '../utils/logger'; import { useNotifications } from './useNotifications'; @@ -118,204 +114,6 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(result.current.notifications[1].notifications.length).toBe(2); }); - it('should fetch detailed notifications with success', async () => { - const mockRepository = { - name: 'notifications-test', - full_name: 'gitify-app/notifications-test', - html_url: 'https://github.com/gitify-app/notifications-test', - owner: { - login: 'gitify-app', - avatar_url: 'https://avatar.url', - type: 'Organization', - }, - }; - - const mockNotifications = [ - { - id: '1', - unread: true, - updated_at: '2024-01-01T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is a check suite workflow.', - type: 'CheckSuite', - url: null, - latest_comment_url: null, - }, - repository: mockRepository, - }, - { - id: '2', - unread: true, - updated_at: '2024-02-26T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is a Discussion.', - type: 'Discussion', - url: null, - latest_comment_url: null, - }, - repository: mockRepository, - }, - { - id: '3', - unread: true, - updated_at: '2024-01-01T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is an Issue.', - type: 'Issue', - url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/3', - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/issues/3/comments', - }, - repository: mockRepository, - }, - { - id: '4', - unread: true, - updated_at: '2024-01-01T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is a Pull Request.', - type: 'PullRequest', - url: 'https://api.github.com/repos/gitify-app/notifications-test/pulls/4', - latest_comment_url: - 'https://api.github.com/repos/gitify-app/notifications-test/issues/4/comments', - }, - repository: mockRepository, - }, - { - id: '5', - unread: true, - updated_at: '2024-01-01T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is an invitation.', - type: 'RepositoryInvitation', - url: null, - latest_comment_url: null, - }, - repository: mockRepository, - }, - { - id: '6', - unread: true, - updated_at: '2024-01-01T00:00:00Z', - reason: 'subscribed', - subject: { - title: 'This is a workflow run.', - type: 'WorkflowRun', - url: null, - latest_comment_url: null, - }, - repository: mockRepository, - }, - ]; - - nock('https://api.github.com') - .get('/notifications?participating=false') - .reply(200, mockNotifications); - - nock('https://api.github.com') - .post('/graphql') - .reply(200, { - data: { - search: { - nodes: [ - { - title: 'This is a Discussion.', - stateReason: null, - isAnswered: true, - url: 'https://github.com/gitify-app/notifications-test/discussions/612', - author: { - login: 'discussion-creator', - url: 'https://github.com/discussion-creator', - avatar_url: - 'https://avatars.githubusercontent.com/u/133795385?s=200&v=4', - type: 'User', - }, - comments: { - nodes: [ - { - databaseId: 2297637, - createdAt: '2022-03-04T20:39:44Z', - author: { - login: 'comment-user', - url: 'https://github.com/comment-user', - avatar_url: - 'https://avatars.githubusercontent.com/u/1?v=4', - type: 'User', - }, - replies: { - nodes: [], - }, - }, - ], - }, - labels: null, - }, - ], - }, - }, - }); - - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/3') - .reply(200, { - state: 'closed', - merged: true, - user: mockNotificationUser, - labels: [], - }); - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/3/comments') - .reply(200, { - user: mockNotificationUser, - }); - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/4') - .reply(200, { - state: 'closed', - merged: false, - user: mockNotificationUser, - labels: [], - }); - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/pulls/4/reviews') - .reply(200, {}); - nock('https://api.github.com') - .get('/repos/gitify-app/notifications-test/issues/4/comments') - .reply(200, { - user: mockNotificationUser, - }); - - const { result } = renderHook(() => useNotifications()); - - act(() => { - result.current.fetchNotifications({ - auth: { - accounts: [mockGitHubCloudAccount], - }, - settings: { - ...mockSettings, - detailedNotifications: true, - }, - }); - }); - - expect(result.current.status).toBe('loading'); - - await waitFor(() => { - expect(result.current.status).toBe('success'); - }); - - expect(result.current.notifications[0].account.hostname).toBe( - 'github.com', - ); - expect(result.current.notifications[0].notifications.length).toBe(6); - }); - it('should fetch notifications with same failures', async () => { const code = AxiosError.ERR_BAD_REQUEST; const status = 401; diff --git a/src/renderer/utils/api/client.test.ts b/src/renderer/utils/api/client.test.ts index 0b16d0467..ac86c7688 100644 --- a/src/renderer/utils/api/client.test.ts +++ b/src/renderer/utils/api/client.test.ts @@ -1,14 +1,22 @@ import axios, { type AxiosPromise, type AxiosResponse } from 'axios'; import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks'; +import { createPartialMockNotification } from '../../__mocks__/notifications-mocks'; import { mockToken } from '../../__mocks__/state-mocks'; +import { Constants } from '../../constants'; import type { Hostname, Link, SettingsState, Token } from '../../types'; import * as logger from '../../utils/logger'; import { mockAuthHeaders, mockNonCachedAuthHeaders, } from './__mocks__/request-mocks'; +import { mockGitHubNotifications } from './__mocks__/response-mocks'; import { + fetchAuthenticatedUserDetails, + fetchDiscussionByNumber, + fetchIssueByNumber, + fetchNotificationDetailsForList, + fetchPullByNumber, getHtmlUrl, headNotifications, ignoreNotificationThreadSubscription, @@ -16,6 +24,17 @@ import { markNotificationThreadAsDone, markNotificationThreadAsRead, } from './client'; +import { + FetchAuthenticatedUserDetailsDocument, + type FetchAuthenticatedUserDetailsQuery, + FetchDiscussionByNumberDocument, + type FetchDiscussionByNumberQuery, + FetchIssueByNumberDocument, + type FetchIssueByNumberQuery, + FetchPullRequestByNumberDocument, + type FetchPullRequestByNumberQuery, +} from './graphql/generated/graphql'; +import type { ExecutionResultWithHeaders } from './request'; import * as apiRequests from './request'; jest.mock('axios'); @@ -163,4 +182,208 @@ describe('renderer/utils/api/client.ts', () => { expect(rendererLogErrorSpy).toHaveBeenCalledTimes(1); }); }); + + it('fetchAuthenticatedUserDetails calls performGraphQLRequest with correct args', async () => { + const performGraphQLRequestSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequest', + ); + + performGraphQLRequestSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchAuthenticatedUserDetails(mockGitHubHostname, mockToken); + + expect(performGraphQLRequestSpy).toHaveBeenCalledWith( + 'https://api.github.com/graphql', + mockToken, + FetchAuthenticatedUserDetailsDocument, + ); + }); + + it('fetchDiscussionByNumber calls performGraphQLRequest with correct args', async () => { + const performGraphQLRequestSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequest', + ); + + const mockNotification = createPartialMockNotification({ + title: 'Some discussion', + url: 'https://api.github.com/repos/gitify-app/gitify/discussion/123' as Link, + type: 'Discussion', + }); + + performGraphQLRequestSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchDiscussionByNumber(mockNotification); + + expect(performGraphQLRequestSpy).toHaveBeenCalledWith( + 'https://api.github.com/graphql', + mockToken, + FetchDiscussionByNumberDocument, + { + owner: mockNotification.repository.owner.login, + name: mockNotification.repository.name, + number: 123, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS, + lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES, + includeIsAnswered: true, + }, + ); + }); + + it('fetchIssueByNumber calls performGraphQLRequest with correct args', async () => { + const performGraphQLRequestSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequest', + ); + + const mockNotification = createPartialMockNotification({ + title: 'Some issue', + url: 'https://api.github.com/repos/gitify-app/gitify/issues/123' as Link, + type: 'Issue', + }); + + performGraphQLRequestSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchIssueByNumber(mockNotification); + + expect(performGraphQLRequestSpy).toHaveBeenCalledWith( + 'https://api.github.com/graphql', + mockToken, + FetchIssueByNumberDocument, + { + owner: mockNotification.repository.owner.login, + name: mockNotification.repository.name, + number: 123, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, + }, + ); + }); + + it('fetchPullByNumber calls performGraphQLRequest with correct args', async () => { + const performGraphQLRequestSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequest', + ); + + const mockNotification = createPartialMockNotification({ + title: 'Some pull request', + url: 'https://api.github.com/repos/gitify-app/gitify/pulls/123' as Link, + type: 'PullRequest', + }); + + performGraphQLRequestSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchPullByNumber(mockNotification); + + expect(performGraphQLRequestSpy).toHaveBeenCalledWith( + 'https://api.github.com/graphql', + mockToken, + FetchPullRequestByNumberDocument, + { + owner: mockNotification.repository.owner.login, + name: mockNotification.repository.name, + number: 123, + firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, + lastReviews: Constants.GRAPHQL_ARGS.LAST_REVIEWS, + }, + ); + }); + + describe('fetchNotificationDetailsForList', () => { + it('fetchNotificationDetailsForList returns empty map if no notifications', async () => { + const performGraphQLRequestStringSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequestString', + ); + + const mockNotification = createPartialMockNotification({ + title: 'Some commit', + url: 'https://api.github.com/repos/gitify-app/gitify/commit/123' as Link, + type: 'Commit', + }); + + performGraphQLRequestStringSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchNotificationDetailsForList([mockNotification]); + + expect(performGraphQLRequestStringSpy).not.toHaveBeenCalled(); + }); + + it('fetchNotificationDetailsForList returns empty map if no supported notifications', async () => { + const performGraphQLRequestStringSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequestString', + ); + + performGraphQLRequestStringSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchNotificationDetailsForList([]); + + expect(performGraphQLRequestStringSpy).not.toHaveBeenCalled(); + }); + + it('fetchNotificationDetailsForList returns empty map if no notifications', async () => { + const performGraphQLRequestStringSpy = jest.spyOn( + apiRequests, + 'performGraphQLRequestString', + ); + + performGraphQLRequestStringSpy.mockResolvedValue({ + data: {}, + headers: {}, + } as ExecutionResultWithHeaders); + + await fetchNotificationDetailsForList(mockGitHubNotifications); + + expect(performGraphQLRequestStringSpy).toHaveBeenCalledWith( + 'https://api.github.com/graphql', + mockToken, + expect.stringMatching(/node0|node1/), + { + firstClosingIssues: 100, + firstLabels: 100, + includeIsAnswered: true, + isDiscussionNotification0: false, + isDiscussionNotification1: false, + isIssueNotification0: true, + isIssueNotification1: true, + isPullRequestNotification0: false, + isPullRequestNotification1: false, + lastComments: 1, + lastReplies: 10, + lastReviews: 100, + lastThreadedComments: 10, + name0: 'notifications-test', + name1: 'notifications-test', + number0: 1, + number1: 4, + owner0: 'gitify-app', + owner1: 'gitify-app', + }, + ); + }); + }); }); diff --git a/src/renderer/utils/api/client.ts b/src/renderer/utils/api/client.ts index 7806dc75b..18fccfebd 100644 --- a/src/renderer/utils/api/client.ts +++ b/src/renderer/utils/api/client.ts @@ -1,6 +1,7 @@ import type { AxiosPromise } from 'axios'; import type { ExecutionResult } from 'graphql'; +import { Constants } from '../../constants'; import type { Account, GitifyNotification, @@ -11,6 +12,7 @@ import type { } from '../../types'; import { isAnsweredDiscussionFeatureSupported } from '../features'; import { rendererLogError } from '../logger'; +import { createNotificationHandler } from '../notifications/handlers'; import { FetchAuthenticatedUserDetailsDocument, type FetchAuthenticatedUserDetailsQuery, @@ -18,13 +20,16 @@ import { type FetchDiscussionByNumberQuery, FetchIssueByNumberDocument, type FetchIssueByNumberQuery, + type FetchMergedDetailsTemplateQuery, FetchPullRequestByNumberDocument, type FetchPullRequestByNumberQuery, } from './graphql/generated/graphql'; +import { MergeQueryBuilder } from './graphql/MergeQueryBuilder'; import { apiRequestAuth, type ExecutionResultWithHeaders, performGraphQLRequest, + performGraphQLRequestString, } from './request'; import type { NotificationThreadSubscription, @@ -195,76 +200,163 @@ export async function fetchAuthenticatedUserDetails( } /** - * Fetch GitHub Issue by Issue Number. + * Fetch GitHub Discussion by Discussion Number. */ -export async function fetchIssueByNumber( +export async function fetchDiscussionByNumber( notification: GitifyNotification, -): Promise> { +): Promise> { const url = getGitHubGraphQLUrl(notification.account.hostname); const number = getNumberFromUrl(notification.subject.url); return performGraphQLRequest( url.toString() as Link, notification.account.token, - FetchIssueByNumberDocument, + FetchDiscussionByNumberDocument, { owner: notification.repository.owner.login, name: notification.repository.name, number: number, - firstLabels: 100, - lastComments: 1, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS, + lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES, + includeIsAnswered: isAnsweredDiscussionFeatureSupported( + notification.account, + ), }, ); } /** - * Fetch GitHub Pull Request by PR Number. + * Fetch GitHub Issue by Issue Number. */ -export async function fetchPullByNumber( +export async function fetchIssueByNumber( notification: GitifyNotification, -): Promise> { +): Promise> { const url = getGitHubGraphQLUrl(notification.account.hostname); const number = getNumberFromUrl(notification.subject.url); return performGraphQLRequest( url.toString() as Link, notification.account.token, - FetchPullRequestByNumberDocument, + FetchIssueByNumberDocument, { owner: notification.repository.owner.login, name: notification.repository.name, number: number, - firstLabels: 100, - firstClosingIssues: 100, - lastComments: 1, - lastReviews: 100, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, }, ); } /** - * Fetch GitHub Discussion by Discussion Number. + * Fetch GitHub Pull Request by PR Number. */ -export async function fetchDiscussionByNumber( +export async function fetchPullByNumber( notification: GitifyNotification, -): Promise> { +): Promise> { const url = getGitHubGraphQLUrl(notification.account.hostname); const number = getNumberFromUrl(notification.subject.url); return performGraphQLRequest( url.toString() as Link, notification.account.token, - FetchDiscussionByNumberDocument, + FetchPullRequestByNumberDocument, { owner: notification.repository.owner.login, name: notification.repository.name, number: number, - lastComments: 10, - lastReplies: 10, - firstLabels: 100, - includeIsAnswered: isAnsweredDiscussionFeatureSupported( - notification.account, - ), + firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, + lastReviews: Constants.GRAPHQL_ARGS.LAST_REVIEWS, }, ); +} /** + * Fetch notification details for supported types (ie: Discussions, Issues and Pull Requests). + + * This significantly reduces the amount of API calls by performing a building a merged GraphQL query, + * making the most efficient use of the available GitHub API quota limits. + */ +export async function fetchNotificationDetailsForList( + notifications: GitifyNotification[], +): Promise< + Map +> { + const results = new Map< + GitifyNotification, + FetchMergedDetailsTemplateQuery['repository'] + >(); + + if (!notifications.length) { + return results; + } + + // Build merged query using the builder + const builder = new MergeQueryBuilder(); + const aliasToNotification = new Map(); + let hasSupportedNotification = false; + + for (const notification of notifications) { + const handler = createNotificationHandler(notification); + if (!handler.supportsMergedQueryEnrichment) { + continue; + } + + hasSupportedNotification = true; + + const alias = builder.addNode({ + owner: notification.repository.owner.login, + name: notification.repository.name, + number: getNumberFromUrl(notification.subject.url), + isDiscussionNotification: notification.subject.type === 'Discussion', + isIssueNotification: notification.subject.type === 'Issue', + isPullRequestNotification: notification.subject.type === 'PullRequest', + }); + + aliasToNotification.set(alias, notification); + } + + if (!hasSupportedNotification) { + return results; + } + + builder.setSharedVariables({ + includeIsAnswered: isAnsweredDiscussionFeatureSupported( + notifications[0].account, + ), + firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES, + firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, + lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS, + lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES, + lastReviews: Constants.GRAPHQL_ARGS.LAST_REVIEWS, + }); + + const query = builder.getGraphQLQuery(); + const variables = builder.getGraphQLVariables(); + + const url = getGitHubGraphQLUrl(notifications[0].account.hostname); + + const response = await performGraphQLRequestString( + url.toString() as Link, + notifications[0].account.token, + query, + variables, + ); + + const data = response.data as Record | undefined; + if (data) { + for (const [alias, notification] of aliasToNotification) { + const repoData = data[alias] as Record | undefined; + if (repoData) { + const fragment = Object.values( + repoData, + )[0] as FetchMergedDetailsTemplateQuery['repository']; + results.set(notification, fragment); + } + } + } + + return results; } diff --git a/src/renderer/utils/api/graphql/MergeQueryBuilder.test.ts b/src/renderer/utils/api/graphql/MergeQueryBuilder.test.ts new file mode 100644 index 000000000..09d5c2e15 --- /dev/null +++ b/src/renderer/utils/api/graphql/MergeQueryBuilder.test.ts @@ -0,0 +1,134 @@ +import { + type FetchBatchMergedTemplateIndexedBaseVariables, + type FetchBatchMergedTemplateNonIndexedVariables, + MergeQueryBuilder, +} from './MergeQueryBuilder'; + +describe('renderer/utils/api/graphql/MergeQueryBuilder.ts', () => { + const sharedVars: FetchBatchMergedTemplateNonIndexedVariables = { + lastComments: 5, + lastThreadedComments: 3, + lastReplies: 2, + lastReviews: 4, + firstLabels: 10, + firstClosingIssues: 8, + includeIsAnswered: true, + }; + + const nodeVarsA: FetchBatchMergedTemplateIndexedBaseVariables = { + owner: 'octocat', + name: 'hello-world', + number: 123, + isDiscussionNotification: false, + isIssueNotification: true, + isPullRequestNotification: false, + }; + + const nodeVarsB: FetchBatchMergedTemplateIndexedBaseVariables = { + owner: 'gitify-app', + name: 'gitify', + number: 456, + isDiscussionNotification: true, + isIssueNotification: false, + isPullRequestNotification: true, + }; + + it('builds a query with one node and shared variables', () => { + const builder = new MergeQueryBuilder().setSharedVariables(sharedVars); + + const alias = builder.addNode(nodeVarsA); + expect(alias).toBe('node0'); + + const query = builder.getGraphQLQuery(); + const vars = builder.getGraphQLVariables(); + + // Variable definitions should include non-indexed and indexed 0 + expect(query).toContain('query FetchMergedNotifications('); + expect(query).toContain('$lastComments: Int'); + expect(query).toContain('$lastThreadedComments: Int'); + expect(query).toContain('$lastReplies: Int'); + expect(query).toContain('$lastReviews: Int'); + expect(query).toContain('$firstLabels: Int'); + expect(query).toContain('$firstClosingIssues: Int'); + expect(query).toContain('$includeIsAnswered: Boolean!'); + + expect(query).toContain('$owner0: String!'); + expect(query).toContain('$name0: String!'); + expect(query).toContain('$number0: Int!'); + expect(query).toContain('$isDiscussionNotification0: Boolean!'); + expect(query).toContain('$isIssueNotification0: Boolean!'); + expect(query).toContain('$isPullRequestNotification0: Boolean!'); + + // Selection should be aliased and have indexed variables applied + expect(query).toContain('node0: repository'); + expect(query).toContain('discussion(number: $number0)'); + expect(query).toContain('@include(if: $isDiscussionNotification0)'); + expect(query).toContain('issue(number: $number0)'); + expect(query).toContain('@include(if: $isIssueNotification0)'); + expect(query).toContain('pullRequest(number: $number0)'); + expect(query).toContain('@include(if: $isPullRequestNotification0)'); + + // Fragments should be appended + expect(query).toContain('fragment PullRequestDetails'); + expect(query).toContain('fragment IssueDetails'); + expect(query).toContain('fragment DiscussionDetails'); + + // Variables should include both shared and indexed 0 + expect(vars).toMatchObject({ + lastComments: 5, + lastThreadedComments: 3, + lastReplies: 2, + lastReviews: 4, + firstLabels: 10, + firstClosingIssues: 8, + includeIsAnswered: true, + owner0: 'octocat', + name0: 'hello-world', + number0: 123, + isDiscussionNotification0: false, + isIssueNotification0: true, + isPullRequestNotification0: false, + }); + }); + + it('builds a query with multiple nodes and increments aliases/definitions', () => { + const builder = new MergeQueryBuilder().setSharedVariables(sharedVars); + + builder.addNode(nodeVarsA); + + const alias1 = builder.addNode(nodeVarsB); + expect(alias1).toBe('node1'); + + const query = builder.getGraphQLQuery('CustomDoc'); + const vars = builder.getGraphQLVariables(); + + // Custom document name + expect(query).toContain('query CustomDoc('); + + // Both node aliases present + expect(query).toContain('node0: repository'); + expect(query).toContain('node1: repository'); + + // Indexed var definitions for both indices + expect(query).toContain('$owner0: String!'); + expect(query).toContain('$owner1: String!'); + expect(query).toContain('$number0: Int!'); + expect(query).toContain('$number1: Int!'); + + // Variables map contains both sets + expect(vars).toMatchObject({ + owner0: 'octocat', + name0: 'hello-world', + number0: 123, + isDiscussionNotification0: false, + isIssueNotification0: true, + isPullRequestNotification0: false, + owner1: 'gitify-app', + name1: 'gitify', + number1: 456, + isDiscussionNotification1: true, + isIssueNotification1: false, + isPullRequestNotification1: true, + }); + }); +}); diff --git a/src/renderer/utils/api/graphql/MergeQueryBuilder.ts b/src/renderer/utils/api/graphql/MergeQueryBuilder.ts new file mode 100644 index 000000000..4c1f382e0 --- /dev/null +++ b/src/renderer/utils/api/graphql/MergeQueryBuilder.ts @@ -0,0 +1,176 @@ +import { + type Exact, + FetchMergedDetailsTemplateDocument, + type FetchMergedDetailsTemplateQueryVariables, +} from './generated/graphql'; +import type { FragmentInfo, VariableDef } from './types'; +import { + aliasFieldAndSubstituteIndexedVars, + extractIndexedVariableDefinitions, + extractNonIndexedVariableDefinitions, + extractNonQueryFragments, + extractQueryFragments, +} from './utils'; + +// From merged.graphql template operation +const TemplateDocument = FetchMergedDetailsTemplateDocument; +type TemplateVariables = FetchMergedDetailsTemplateQueryVariables; + +// Preserve exact Scalar-based variable value types via the generated QueryVariables +type VariableValue = TemplateVariables[keyof TemplateVariables]; + +// Split variables by the `INDEX` suffix using the generated QueryVariables type +type IndexedKeys = Extract; +type NonIndexedKeys = Exclude; + +// Transform `${Base}INDEX` keys to just `Base` while preserving value types +type DeindexKeys = { + [K in keyof T as K extends `${infer B}INDEX` ? B : never]: T[K]; +}; + +type FetchBatchMergedTemplateIndexedVariables = Pick< + TemplateVariables, + IndexedKeys +>; + +// Base-key form (e.g., `owner`, `name`, `number`, ...) without `INDEX` suffix +export type FetchBatchMergedTemplateIndexedBaseVariables = + DeindexKeys; + +export type FetchBatchMergedTemplateNonIndexedVariables = Pick< + TemplateVariables, + NonIndexedKeys +>; + +export class MergeQueryBuilder { + private readonly selections: string[] = []; + private readonly variableDefinitions: VariableDef[] = []; + private readonly variableValues: Record = {}; + private readonly fragments: FragmentInfo[] = []; + + // Precomputed, invariant template-derived data (computed once per module load) + private static readonly TEMPLATE_FRAGMENTS = + extractNonQueryFragments(TemplateDocument); + private static readonly TEMPLATE_QUERY_INNER = (() => { + const queryFrags = extractQueryFragments(TemplateDocument); + return queryFrags.length ? queryFrags[0].inner : null; + })(); + private static readonly TEMPLATE_NON_INDEXED_DEFS = + extractNonIndexedVariableDefinitions(TemplateDocument); + private static readonly TEMPLATE_INDEXED_VAR_DEFS = + extractIndexedVariableDefinitions(TemplateDocument); + + constructor() { + // Add precomputed static fragments + this.fragments.push(...MergeQueryBuilder.TEMPLATE_FRAGMENTS); + + // Add common/shared (non-indexed) variable definitions from the template document + this.addVariableDefinitions(MergeQueryBuilder.TEMPLATE_NON_INDEXED_DEFS); + } + + /** + * Add selection set. + */ + addSelection(selection: string): this { + if (selection) { + this.selections.push(selection); + } + return this; + } + + /** + * Add GraphQL variable definition + */ + addVariableDefinitions(defs: VariableDef[]): this { + if (defs) { + this.variableDefinitions.push(...defs); + } + return this; + } + + /** + * Add GraphQL variable with value + */ + setVariableValue(name: string, value: VariableValue): this { + this.variableValues[name] = value; + return this; + } + + /** + * Set shared (non-indexed) variables + */ + setSharedVariables( + values: Exact, + ): this { + for (const [name, value] of Object.entries(values)) { + this.setVariableValue(name, value as VariableValue); + } + return this; + } + + /** + * Add a new selection set (ie: node) to the query. + * @param values The values for the selection set variables/arguments. + * @returns the computed node alias name + */ + addNode(values: Exact): string { + const index = this.selections.length; + const aliasWithIndex = `node${index}`; + this.addSelectionNodeFromQueryTemplate(aliasWithIndex, index, values); + return aliasWithIndex; + } + + /** + * Add a new selection set (ie: node) to the query. + */ + private addSelectionNodeFromQueryTemplate( + alias: string, + index: number, + values: Exact, + ): this { + const selection = aliasFieldAndSubstituteIndexedVars( + alias, + index, + MergeQueryBuilder.TEMPLATE_QUERY_INNER, + ); + this.addSelection(selection); + + const renamedIndexVarDefs: VariableDef[] = + MergeQueryBuilder.TEMPLATE_INDEXED_VAR_DEFS.map((varDef) => { + return { + name: varDef.name.replace('INDEX', `${index}`), + type: varDef.type, + }; + }); + + this.addVariableDefinitions(renamedIndexVarDefs); + + for (const [base, val] of Object.entries(values)) { + this.setVariableValue(`${base}${index}`, val); + } + + return this; + } + + /** + * Returns a formatted GraphQL Query operation document/statement. + */ + getGraphQLQuery(docName = 'FetchMergedNotifications'): string { + const variablesDefinitions = this.variableDefinitions + .map((varDef) => `$${varDef.name}: ${varDef.type}`) + .join(', '); + + const selections = this.selections.join('\n'); + + const fragments = this.fragments.map((f) => f.printed).join('\n'); + + return `query ${docName}(${variablesDefinitions}) {\n${selections}\n}\n\n${fragments}\n`; + } + + /** + * Return the GraphQL Query Variables. + */ + getGraphQLVariables(): Record { + return this.variableValues; + } +} diff --git a/src/renderer/utils/api/graphql/common.graphql b/src/renderer/utils/api/graphql/common.graphql index 6acf59c00..b1e5a8aef 100644 --- a/src/renderer/utils/api/graphql/common.graphql +++ b/src/renderer/utils/api/graphql/common.graphql @@ -1,7 +1,7 @@ fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } diff --git a/src/renderer/utils/api/graphql/discussion.graphql b/src/renderer/utils/api/graphql/discussion.graphql index 63a70304d..e17d20e52 100644 --- a/src/renderer/utils/api/graphql/discussion.graphql +++ b/src/renderer/utils/api/graphql/discussion.graphql @@ -1,8 +1,10 @@ +#import './common.graphql' + query FetchDiscussionByNumber( $owner: String! $name: String! $number: Int! - $lastComments: Int + $lastThreadedComments: Int $lastReplies: Int $firstLabels: Int $includeIsAnswered: Boolean! @@ -24,7 +26,7 @@ fragment DiscussionDetails on Discussion { author { ...AuthorFields } - comments(last: $lastComments) { + comments(last: $lastThreadedComments) { totalCount nodes { ...DiscussionCommentFields diff --git a/src/renderer/utils/api/graphql/generated/gql.ts b/src/renderer/utils/api/graphql/generated/gql.ts index 806d82aeb..e72c24c08 100644 --- a/src/renderer/utils/api/graphql/generated/gql.ts +++ b/src/renderer/utils/api/graphql/generated/gql.ts @@ -15,16 +15,18 @@ import * as types from './graphql'; * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": typeof types.AuthorFieldsFragmentDoc, - "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": typeof types.FetchDiscussionByNumberDocument, + "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": typeof types.AuthorFieldsFragmentDoc, + "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastThreadedComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastThreadedComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": typeof types.FetchDiscussionByNumberDocument, "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": typeof types.FetchIssueByNumberDocument, + "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": typeof types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": typeof types.FetchPullRequestByNumberDocument, "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, }; const documents: Documents = { - "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": types.AuthorFieldsFragmentDoc, - "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": types.FetchDiscussionByNumberDocument, + "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": types.AuthorFieldsFragmentDoc, + "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastThreadedComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastThreadedComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}": types.FetchDiscussionByNumberDocument, "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": types.FetchIssueByNumberDocument, + "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": types.FetchPullRequestByNumberDocument, "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, }; @@ -32,15 +34,19 @@ const documents: Documents = { /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}"): typeof import('./graphql').AuthorFieldsFragmentDoc; +export function graphql(source: "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}"): typeof import('./graphql').AuthorFieldsFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}"): typeof import('./graphql').FetchDiscussionByNumberDocument; +export function graphql(source: "query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastThreadedComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) {\n repository(owner: $owner, name: $name) {\n discussion(number: $number) {\n ...DiscussionDetails\n }\n }\n}\n\nfragment DiscussionDetails on Discussion {\n __typename\n number\n title\n stateReason\n isAnswered @include(if: $includeIsAnswered)\n url\n author {\n ...AuthorFields\n }\n comments(last: $lastThreadedComments) {\n totalCount\n nodes {\n ...DiscussionCommentFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}\n\nfragment CommentFields on DiscussionComment {\n databaseId\n createdAt\n author {\n ...AuthorFields\n }\n url\n}\n\nfragment DiscussionCommentFields on DiscussionComment {\n ...CommentFields\n replies(last: $lastReplies) {\n totalCount\n nodes {\n ...CommentFields\n }\n }\n}"): typeof import('./graphql').FetchDiscussionByNumberDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}"): typeof import('./graphql').FetchIssueByNumberDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}"): typeof import('./graphql').FetchMergedDetailsTemplateDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index 82d3cf2ae..fda34b59c 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -35894,15 +35894,15 @@ export type WorkflowsParametersInput = { export type _Entity = Issue; -type AuthorFields_Bot_Fragment = { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' }; +type AuthorFields_Bot_Fragment = { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' }; -type AuthorFields_EnterpriseUserAccount_Fragment = { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' }; +type AuthorFields_EnterpriseUserAccount_Fragment = { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' }; -type AuthorFields_Mannequin_Fragment = { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' }; +type AuthorFields_Mannequin_Fragment = { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' }; -type AuthorFields_Organization_Fragment = { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' }; +type AuthorFields_Organization_Fragment = { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' }; -type AuthorFields_User_Fragment = { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' }; +type AuthorFields_User_Fragment = { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' }; export type AuthorFieldsFragment = | AuthorFields_Bot_Fragment @@ -35918,7 +35918,7 @@ export type FetchDiscussionByNumberQueryVariables = Exact<{ owner: Scalars['String']['input']; name: Scalars['String']['input']; number: Scalars['Int']['input']; - lastComments?: InputMaybe; + lastThreadedComments?: InputMaybe; lastReplies?: InputMaybe; firstLabels?: InputMaybe; includeIsAnswered: Scalars['Boolean']['input']; @@ -35926,65 +35926,65 @@ export type FetchDiscussionByNumberQueryVariables = Exact<{ export type FetchDiscussionByNumberQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', discussion?: { __typename: 'Discussion', number: number, title: string, stateReason?: DiscussionStateReason | null, isAnswered?: boolean | null, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, replies: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null } | null }; export type DiscussionDetailsFragment = { __typename: 'Discussion', number: number, title: string, stateReason?: DiscussionStateReason | null, isAnswered?: boolean | null, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, replies: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null }; export type CommentFieldsFragment = { __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null }; export type DiscussionCommentFieldsFragment = { __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, replies: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null }; export type FetchIssueByNumberQueryVariables = Exact<{ @@ -35997,33 +35997,150 @@ export type FetchIssueByNumberQueryVariables = Exact<{ export type FetchIssueByNumberQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', issue?: { __typename: 'Issue', number: number, title: string, url: any, state: IssueState, stateReason?: IssueStateReason | null, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null } | null }; export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: string, url: any, state: IssueState, stateReason?: IssueStateReason | null, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null }; +export type FetchMergedDetailsTemplateQueryVariables = Exact<{ + ownerINDEX: Scalars['String']['input']; + nameINDEX: Scalars['String']['input']; + numberINDEX: Scalars['Int']['input']; + isDiscussionNotificationINDEX: Scalars['Boolean']['input']; + isIssueNotificationINDEX: Scalars['Boolean']['input']; + isPullRequestNotificationINDEX: Scalars['Boolean']['input']; + lastComments?: InputMaybe; + lastThreadedComments?: InputMaybe; + lastReplies?: InputMaybe; + lastReviews?: InputMaybe; + firstLabels?: InputMaybe; + firstClosingIssues?: InputMaybe; + includeIsAnswered: Scalars['Boolean']['input']; +}>; + + +export type FetchMergedDetailsTemplateQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', discussion?: { __typename: 'Discussion', number: number, title: string, stateReason?: DiscussionStateReason | null, isAnswered?: boolean | null, url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, replies: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: any, state: IssueState, stateReason?: IssueStateReason | null, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: any, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, reviews?: { __typename?: 'PullRequestReviewConnection', totalCount: number, nodes?: Array<{ __typename?: 'PullRequestReview', state: PullRequestReviewState, author?: + | { __typename?: 'Bot', login: string } + | { __typename?: 'EnterpriseUserAccount', login: string } + | { __typename?: 'Mannequin', login: string } + | { __typename?: 'Organization', login: string } + | { __typename?: 'User', login: string } + | null } | null> | null } | null, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null, closingIssuesReferences?: { __typename?: 'IssueConnection', nodes?: Array<{ __typename?: 'Issue', number: number } | null> | null } | null } | null } | null }; + +export type MergedDetailsQueryTemplateFragment = { __typename?: 'Query', repository?: { __typename?: 'Repository', discussion?: { __typename: 'Discussion', number: number, title: string, stateReason?: DiscussionStateReason | null, isAnswered?: boolean | null, url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, replies: { __typename?: 'DiscussionCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'DiscussionComment', databaseId?: number | null, createdAt: any, url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null, issue?: { __typename: 'Issue', number: number, title: string, url: any, state: IssueState, stateReason?: IssueStateReason | null, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: any, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } + | null } | null> | null }, reviews?: { __typename?: 'PullRequestReviewConnection', totalCount: number, nodes?: Array<{ __typename?: 'PullRequestReview', state: PullRequestReviewState, author?: + | { __typename?: 'Bot', login: string } + | { __typename?: 'EnterpriseUserAccount', login: string } + | { __typename?: 'Mannequin', login: string } + | { __typename?: 'Organization', login: string } + | { __typename?: 'User', login: string } + | null } | null> | null } | null, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null, closingIssuesReferences?: { __typename?: 'IssueConnection', nodes?: Array<{ __typename?: 'Issue', number: number } | null> | null } | null } | null } | null }; + export type FetchPullRequestByNumberQueryVariables = Exact<{ owner: Scalars['String']['input']; name: Scalars['String']['input']; @@ -36036,17 +36153,17 @@ export type FetchPullRequestByNumberQueryVariables = Exact<{ export type FetchPullRequestByNumberQuery = { __typename?: 'Query', repository?: { __typename?: 'Repository', pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: any, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, reviews?: { __typename?: 'PullRequestReviewConnection', totalCount: number, nodes?: Array<{ __typename?: 'PullRequestReview', state: PullRequestReviewState, author?: | { __typename?: 'Bot', login: string } | { __typename?: 'EnterpriseUserAccount', login: string } @@ -36056,17 +36173,17 @@ export type FetchPullRequestByNumberQuery = { __typename?: 'Query', repository?: | null } | null> | null } | null, labels?: { __typename?: 'LabelConnection', nodes?: Array<{ __typename?: 'Label', name: string } | null> | null } | null, closingIssuesReferences?: { __typename?: 'IssueConnection', nodes?: Array<{ __typename?: 'Issue', number: number } | null> | null } | null } | null } | null }; export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: number, title: string, url: any, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone?: { __typename?: 'Milestone', state: MilestoneState, title: string } | null, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number, nodes?: Array<{ __typename?: 'IssueComment', url: any, author?: - | { __typename?: 'Bot', login: string, avatarUrl: any, htmlUrl: any, type: 'Bot' } - | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: any, htmlUrl: any, type: 'EnterpriseUserAccount' } - | { __typename?: 'Mannequin', login: string, avatarUrl: any, htmlUrl: any, type: 'Mannequin' } - | { __typename?: 'Organization', login: string, avatarUrl: any, htmlUrl: any, type: 'Organization' } - | { __typename?: 'User', login: string, avatarUrl: any, htmlUrl: any, type: 'User' } + | { __typename?: 'Bot', login: string, htmlUrl: any, avatarUrl: any, type: 'Bot' } + | { __typename?: 'EnterpriseUserAccount', login: string, htmlUrl: any, avatarUrl: any, type: 'EnterpriseUserAccount' } + | { __typename?: 'Mannequin', login: string, htmlUrl: any, avatarUrl: any, type: 'Mannequin' } + | { __typename?: 'Organization', login: string, htmlUrl: any, avatarUrl: any, type: 'Organization' } + | { __typename?: 'User', login: string, htmlUrl: any, avatarUrl: any, type: 'User' } | null } | null> | null }, reviews?: { __typename?: 'PullRequestReviewConnection', totalCount: number, nodes?: Array<{ __typename?: 'PullRequestReview', state: PullRequestReviewState, author?: | { __typename?: 'Bot', login: string } | { __typename?: 'EnterpriseUserAccount', login: string } @@ -36110,7 +36227,7 @@ export const AuthorFieldsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } `, {"fragmentName":"AuthorFields"}) as unknown as TypedDocumentString; @@ -36126,7 +36243,7 @@ export const CommentFieldsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename }`, {"fragmentName":"CommentFields"}) as unknown as TypedDocumentString; export const DiscussionCommentFieldsFragmentDoc = new TypedDocumentString(` @@ -36142,7 +36259,7 @@ export const DiscussionCommentFieldsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment CommentFields on DiscussionComment { @@ -36164,7 +36281,7 @@ export const DiscussionDetailsFragmentDoc = new TypedDocumentString(` author { ...AuthorFields } - comments(last: $lastComments) { + comments(last: $lastThreadedComments) { totalCount nodes { ...DiscussionCommentFields @@ -36179,7 +36296,7 @@ export const DiscussionDetailsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment CommentFields on DiscussionComment { @@ -36237,7 +36354,7 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment MilestoneFields on Milestone { @@ -36297,7 +36414,7 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment MilestoneFields on Milestone { @@ -36310,8 +36427,146 @@ fragment PullRequestReviewFields on PullRequestReview { login } }`, {"fragmentName":"PullRequestDetails"}) as unknown as TypedDocumentString; +export const MergedDetailsQueryTemplateFragmentDoc = new TypedDocumentString(` + fragment MergedDetailsQueryTemplate on Query { + repository(owner: $ownerINDEX, name: $nameINDEX) { + discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) { + ...DiscussionDetails + } + issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) { + ...IssueDetails + } + pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) { + ...PullRequestDetails + } + } +} + fragment AuthorFields on Actor { + login + htmlUrl: url + avatarUrl: avatarUrl + type: __typename +} +fragment MilestoneFields on Milestone { + state + title +} +fragment DiscussionDetails on Discussion { + __typename + number + title + stateReason + isAnswered @include(if: $includeIsAnswered) + url + author { + ...AuthorFields + } + comments(last: $lastThreadedComments) { + totalCount + nodes { + ...DiscussionCommentFields + } + } + labels(first: $firstLabels) { + nodes { + name + } + } +} +fragment CommentFields on DiscussionComment { + databaseId + createdAt + author { + ...AuthorFields + } + url +} +fragment DiscussionCommentFields on DiscussionComment { + ...CommentFields + replies(last: $lastReplies) { + totalCount + nodes { + ...CommentFields + } + } +} +fragment IssueDetails on Issue { + __typename + number + title + url + state + stateReason + milestone { + ...MilestoneFields + } + author { + ...AuthorFields + } + comments(last: $lastComments) { + totalCount + nodes { + url + author { + ...AuthorFields + } + } + } + labels(first: $firstLabels) { + nodes { + name + } + } +} +fragment PullRequestDetails on PullRequest { + __typename + number + title + url + state + merged + isDraft + isInMergeQueue + milestone { + ...MilestoneFields + } + author { + ...AuthorFields + } + comments(last: $lastComments) { + totalCount + nodes { + url + author { + ...AuthorFields + } + } + } + reviews(last: $lastReviews) { + totalCount + nodes { + ...PullRequestReviewFields + } + } + labels(first: $firstLabels) { + nodes { + name + } + } + closingIssuesReferences(first: $firstClosingIssues) { + nodes { + number + } + } +} +fragment PullRequestReviewFields on PullRequestReview { + state + author { + login + } +}`, {"fragmentName":"MergedDetailsQueryTemplate"}) as unknown as TypedDocumentString; export const FetchDiscussionByNumberDocument = new TypedDocumentString(` - query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) { + query FetchDiscussionByNumber($owner: String!, $name: String!, $number: Int!, $lastThreadedComments: Int, $lastReplies: Int, $firstLabels: Int, $includeIsAnswered: Boolean!) { repository(owner: $owner, name: $name) { discussion(number: $number) { ...DiscussionDetails @@ -36321,7 +36576,7 @@ export const FetchDiscussionByNumberDocument = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment DiscussionDetails on Discussion { @@ -36334,7 +36589,7 @@ fragment DiscussionDetails on Discussion { author { ...AuthorFields } - comments(last: $lastComments) { + comments(last: $lastThreadedComments) { totalCount nodes { ...DiscussionCommentFields @@ -36374,7 +36629,7 @@ export const FetchIssueByNumberDocument = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment MilestoneFields on Milestone { @@ -36409,6 +36664,147 @@ fragment IssueDetails on Issue { } } }`) as unknown as TypedDocumentString; +export const FetchMergedDetailsTemplateDocument = new TypedDocumentString(` + query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) { + ...MergedDetailsQueryTemplate +} + fragment AuthorFields on Actor { + login + htmlUrl: url + avatarUrl: avatarUrl + type: __typename +} +fragment MilestoneFields on Milestone { + state + title +} +fragment DiscussionDetails on Discussion { + __typename + number + title + stateReason + isAnswered @include(if: $includeIsAnswered) + url + author { + ...AuthorFields + } + comments(last: $lastThreadedComments) { + totalCount + nodes { + ...DiscussionCommentFields + } + } + labels(first: $firstLabels) { + nodes { + name + } + } +} +fragment CommentFields on DiscussionComment { + databaseId + createdAt + author { + ...AuthorFields + } + url +} +fragment DiscussionCommentFields on DiscussionComment { + ...CommentFields + replies(last: $lastReplies) { + totalCount + nodes { + ...CommentFields + } + } +} +fragment IssueDetails on Issue { + __typename + number + title + url + state + stateReason + milestone { + ...MilestoneFields + } + author { + ...AuthorFields + } + comments(last: $lastComments) { + totalCount + nodes { + url + author { + ...AuthorFields + } + } + } + labels(first: $firstLabels) { + nodes { + name + } + } +} +fragment MergedDetailsQueryTemplate on Query { + repository(owner: $ownerINDEX, name: $nameINDEX) { + discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) { + ...DiscussionDetails + } + issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) { + ...IssueDetails + } + pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) { + ...PullRequestDetails + } + } +} +fragment PullRequestDetails on PullRequest { + __typename + number + title + url + state + merged + isDraft + isInMergeQueue + milestone { + ...MilestoneFields + } + author { + ...AuthorFields + } + comments(last: $lastComments) { + totalCount + nodes { + url + author { + ...AuthorFields + } + } + } + reviews(last: $lastReviews) { + totalCount + nodes { + ...PullRequestReviewFields + } + } + labels(first: $firstLabels) { + nodes { + name + } + } + closingIssuesReferences(first: $firstClosingIssues) { + nodes { + number + } + } +} +fragment PullRequestReviewFields on PullRequestReview { + state + author { + login + } +}`) as unknown as TypedDocumentString; export const FetchPullRequestByNumberDocument = new TypedDocumentString(` query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) { repository(owner: $owner, name: $name) { @@ -36420,7 +36816,7 @@ export const FetchPullRequestByNumberDocument = new TypedDocumentString(` fragment AuthorFields on Actor { login htmlUrl: url - avatarUrl + avatarUrl: avatarUrl type: __typename } fragment MilestoneFields on Milestone { diff --git a/src/renderer/utils/api/graphql/issue.graphql b/src/renderer/utils/api/graphql/issue.graphql index 091a6543e..ceb1b77e0 100644 --- a/src/renderer/utils/api/graphql/issue.graphql +++ b/src/renderer/utils/api/graphql/issue.graphql @@ -1,3 +1,5 @@ +#import './common.graphql' + query FetchIssueByNumber( $owner: String! $name: String! diff --git a/src/renderer/utils/api/graphql/merged.graphql b/src/renderer/utils/api/graphql/merged.graphql new file mode 100644 index 000000000..ead09e0b1 --- /dev/null +++ b/src/renderer/utils/api/graphql/merged.graphql @@ -0,0 +1,39 @@ +query FetchMergedDetailsTemplate( + # Arguments that will be duplicated per notification. Identified by the suffix `INDEX` + $ownerINDEX: String! + $nameINDEX: String! + $numberINDEX: Int! + $isDiscussionNotificationINDEX: Boolean! + $isIssueNotificationINDEX: Boolean! + $isPullRequestNotificationINDEX: Boolean! + # Arguments that are shared/common for the query + $lastComments: Int + $lastThreadedComments: Int + $lastReplies: Int + $lastReviews: Int + $firstLabels: Int + $firstClosingIssues: Int + $includeIsAnswered: Boolean! +) { + ...MergedDetailsQueryTemplate +} + +fragment MergedDetailsQueryTemplate on Query { + repository(owner: $ownerINDEX, name: $nameINDEX) { + discussion(number: $numberINDEX) + @include(if: $isDiscussionNotificationINDEX) + { + ...DiscussionDetails + } + + issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) { + ...IssueDetails + } + + pullRequest(number: $numberINDEX) + @include(if: $isPullRequestNotificationINDEX) + { + ...PullRequestDetails + } + } +} diff --git a/src/renderer/utils/api/graphql/pull.graphql b/src/renderer/utils/api/graphql/pull.graphql index 9d5862cb6..857580b39 100644 --- a/src/renderer/utils/api/graphql/pull.graphql +++ b/src/renderer/utils/api/graphql/pull.graphql @@ -1,3 +1,5 @@ +#import './common.graphql' + query FetchPullRequestByNumber( $owner: String! $name: String! diff --git a/src/renderer/utils/api/graphql/types.ts b/src/renderer/utils/api/graphql/types.ts new file mode 100644 index 000000000..f5960c472 --- /dev/null +++ b/src/renderer/utils/api/graphql/types.ts @@ -0,0 +1,11 @@ +export type FragmentInfo = { + name: string; + typeCondition: string; + printed: string; + inner: string; +}; + +export type VariableDef = { + name: string; + type: string; +}; diff --git a/src/renderer/utils/api/graphql/utils.test.ts b/src/renderer/utils/api/graphql/utils.test.ts new file mode 100644 index 000000000..27a5ab7af --- /dev/null +++ b/src/renderer/utils/api/graphql/utils.test.ts @@ -0,0 +1,127 @@ +import { + FetchMergedDetailsTemplateDocument, + IssueDetailsFragmentDoc, + MergedDetailsQueryTemplateFragmentDoc, +} from './generated/graphql'; +import { + aliasFieldAndSubstituteIndexedVars, + extractIndexedVariableDefinitions, + extractNonIndexedVariableDefinitions, + extractNonQueryFragments, + extractQueryFragments, +} from './utils'; + +describe('renderer/utils/api/graphql/utils.ts', () => { + describe('getQueryFragmentBody', () => { + it('should extract query fragments from operation document', () => { + const fragments = extractQueryFragments( + FetchMergedDetailsTemplateDocument, + ); + + expect(fragments).not.toBeNull(); + expect(fragments.length).toBe(1); + expect(fragments[0].typeCondition).toEqual('Query'); + expect(fragments[0].inner).toContain('repository'); + expect(fragments[0].inner).toContain('$ownerINDEX'); + expect(fragments[0].inner).toContain('$nameINDEX'); + }); + + it('should extract query fragments from fragment document', () => { + const fragments = extractQueryFragments( + MergedDetailsQueryTemplateFragmentDoc, + ); + + expect(fragments).not.toBeNull(); + expect(fragments.length).toBe(1); + expect(fragments[0].typeCondition).toEqual('Query'); + expect(fragments[0].inner).toContain('repository'); + expect(fragments[0].inner).toContain('$ownerINDEX'); + expect(fragments[0].inner).toContain('$nameINDEX'); + }); + + it('should return null for non-query fragments', () => { + // IssueDetailsFragmentDoc is a graphql document that does not have a query fragment + const fragments = extractQueryFragments(IssueDetailsFragmentDoc); + expect(fragments).toEqual([]); + }); + }); + + describe('extractNonQueryFragments', () => { + it('should extract non-query fragments from FetchBatchMergedTemplateDocument', () => { + const fragments = extractNonQueryFragments( + FetchMergedDetailsTemplateDocument, + ); + + expect(fragments).not.toBeNull(); + expect(fragments.length).toBe(8); + expect(fragments.flatMap((f) => f.typeCondition)).toEqual([ + 'Actor', + 'Milestone', + 'Discussion', + 'DiscussionComment', + 'DiscussionComment', + 'Issue', + 'PullRequest', + 'PullRequestReview', + ]); + }); + }); + + describe('extractIndexedVariableDefinitions', () => { + it('should extract indexed variable definitions from BatchMergedDetailsQueryTemplateFragmentDoc', () => { + const varDefs = extractIndexedVariableDefinitions( + FetchMergedDetailsTemplateDocument, + ); + + expect(varDefs).not.toBeNull(); + expect(varDefs.length).toBe(6); + expect(varDefs.flatMap((v) => v.name)).toEqual([ + 'ownerINDEX', + 'nameINDEX', + 'numberINDEX', + 'isDiscussionNotificationINDEX', + 'isIssueNotificationINDEX', + 'isPullRequestNotificationINDEX', + ]); + }); + }); + + describe('extractNonIndexedVariableDefinitions', () => { + it('should extract non-indexed variable definitions from extractNonIndexedVariableDefinitions', () => { + const varDefs = extractNonIndexedVariableDefinitions( + FetchMergedDetailsTemplateDocument, + ); + + expect(varDefs).not.toBeNull(); + expect(varDefs.length).toBe(7); + expect(varDefs.flatMap((v) => v.name)).toEqual([ + 'lastComments', + 'lastThreadedComments', + 'lastReplies', + 'lastReviews', + 'firstLabels', + 'firstClosingIssues', + 'includeIsAnswered', + ]); + }); + }); + + describe('aliasNodeAndRenameQueryVariables', () => { + it('should add alias, rename indexed vars and leave non-indexed vars unchanged', () => { + const input = + 'repository(owner: $ownerINDEX, name: $name) { issue(number: $number) @include(if: $isIssueNotificationINDEX) { title } }'; + + const result = aliasFieldAndSubstituteIndexedVars('someAlias', 0, input); + + expect(result).toContain('someAlias: repository'); + expect(result).toContain('$owner0'); + expect(result).not.toContain('$ownerINDEX'); + expect(result).toContain('$name'); + expect(result).not.toContain('$name0'); + expect(result).toContain('$number'); + expect(result).not.toContain('$number0'); + expect(result).toContain('$isIssueNotification0'); + expect(result).not.toContain('$isIssueNotificationINDEX'); + }); + }); +}); diff --git a/src/renderer/utils/api/graphql/utils.ts b/src/renderer/utils/api/graphql/utils.ts new file mode 100644 index 000000000..8c703e620 --- /dev/null +++ b/src/renderer/utils/api/graphql/utils.ts @@ -0,0 +1,163 @@ +import { type DocumentNode, parse, print, type TypeNode } from 'graphql'; + +import type { TypedDocumentString } from './generated/graphql'; +import type { FragmentInfo, VariableDef } from './types'; + +const INDEXED_SUFFIX = 'INDEX'; + +// AST-based helpers for robust fragment parsing and deduping + +function toDocumentNode( + doc: TypedDocumentString, +): DocumentNode { + return parse(doc.toString()); +} + +/** + * GraphQL Fragment Utilities + * + * Extract fragments from GraphQL operation document. + */ + +/** + * Return only `Query` fragments from a GraphQL document. + */ +export function extractQueryFragments( + doc: TypedDocumentString, +): FragmentInfo[] { + return extractAllFragments(doc).filter((f) => f.typeCondition === 'Query'); +} + +/** + * Return all non-`Query` fragments from a GraphQL document. + */ +export function extractNonQueryFragments( + doc: TypedDocumentString, +): FragmentInfo[] { + return extractAllFragments(doc).filter((f) => f.typeCondition !== 'Query'); +} + +/** + * Extract all fragments from a GraphQL document with metadata. + */ +function extractAllFragments( + doc: TypedDocumentString, +): FragmentInfo[] { + const ast: DocumentNode = toDocumentNode(doc); + const fragments: FragmentInfo[] = []; + + for (const def of ast.definitions) { + if (def.kind === 'FragmentDefinition') { + const printed = print(def); + // Use AST to print just the selection set and strip braces for `inner` + const printedSel = def.selectionSet ? print(def.selectionSet) : ''; + const open = printedSel.indexOf('{'); + const close = printedSel.lastIndexOf('}'); + + fragments.push({ + name: def.name.value, + typeCondition: def.typeCondition.name.value, + printed: printed, + inner: + open >= 0 && close >= 0 + ? printedSel.slice(open + 1, close).trim() + : '', + }); + } + } + + return fragments; +} + +/** + * Alias the root selection field and suffix key variables with the provided index. + * + * Example: + * repository(owner: $owner, name: $name) { issue(number: $number) { ...IssueDetails } } + * becomes: + * nodeINDEX: repository(owner: $ownerINDEX, name: $nameINDEX) { issue(number: $numberINDEX) { ...IssueDetails } } + */ +export function aliasFieldAndSubstituteIndexedVars( + alias: string, + index: number, + selectionBody: string, +): string { + const idx = String(index); + + // Add alias to the first root field name + const withAlias = selectionBody.replace( + /^\s*([A-Za-z_]\w*)/, + (_m, name: string) => `${alias}: ${name}`, + ); + + // Only alias variables that explicitly end with `INDEX`. + // Example: $ownerINDEX -> $owner0, $nameINDEX -> $name0 + const withIndexedVars = withAlias.replaceAll( + /\$([A-Za-z_]\w*)INDEX\b/g, + (_m, v: string) => `$${v}${idx}`, + ); + + return withIndexedVars; +} + +/** + * GraphQL Variable Definition Utilities + * + * Extract variable definitions from a GraphQL document's operations. + * Returns strings like `$var: Type` suitable for insertion into a query definition. + */ + +export function extractIndexedVariableDefinitions( + doc: TypedDocumentString, +): VariableDef[] { + const all = extractVariableDefinitions(doc); + return filterVariableDefinitionsByIndexSuffix(all, true); +} + +export function extractNonIndexedVariableDefinitions( + doc: TypedDocumentString, +): VariableDef[] { + const all = extractVariableDefinitions(doc); + return filterVariableDefinitionsByIndexSuffix(all, false); +} + +function filterVariableDefinitionsByIndexSuffix( + variableDefs: VariableDef[], + indexed: boolean, +): VariableDef[] { + return variableDefs.filter( + (varDef) => varDef.name.endsWith(INDEXED_SUFFIX) === indexed, + ); +} + +function extractVariableDefinitions( + doc: TypedDocumentString, +): VariableDef[] { + const ast = toDocumentNode(doc); + const defs: VariableDef[] = []; + + for (const def of ast.definitions) { + if (def.kind === 'OperationDefinition' && def.variableDefinitions) { + for (const v of def.variableDefinitions) { + const name = v.variable.name.value; + defs.push({ name: name, type: formatType(v.type) }); + } + } + } + + return defs; +} + +// Format a GraphQL TypeNode to a string (e.g., Int, Boolean!, [String!]) +function formatType(type: TypeNode): string { + switch (type.kind) { + case 'NamedType': + return type.name.value; + case 'NonNullType': + return `${formatType(type.type)}!`; + case 'ListType': + return `[${formatType(type.type)}]`; + default: + return ''; + } +} diff --git a/src/renderer/utils/api/request.test.ts b/src/renderer/utils/api/request.test.ts index cba638f7c..dce087d4b 100644 --- a/src/renderer/utils/api/request.test.ts +++ b/src/renderer/utils/api/request.test.ts @@ -1,15 +1,19 @@ import axios from 'axios'; -import type { Link, Token } from '../../types'; +import { mockToken } from '../../__mocks__/state-mocks'; +import type { Link } from '../../types'; import { mockAuthHeaders, mockNoAuthHeaders, mockNonCachedAuthHeaders, } from './__mocks__/request-mocks'; +import { FetchAuthenticatedUserDetailsDocument } from './graphql/generated/graphql'; import { apiRequest, apiRequestAuth, getHeaders, + performGraphQLRequest, + performGraphQLRequestString, shouldRequestWithNoCache, } from './request'; @@ -23,93 +27,148 @@ describe('renderer/utils/api/request.ts', () => { jest.clearAllMocks(); }); - it('should make a request with the correct parameters', async () => { - const data = { key: 'value' }; + describe('apiRequest', () => { + it('should make a request with the correct parameters', async () => { + const data = { key: 'value' }; - await apiRequest(url, method, data); + await apiRequest(url, method, data); - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, + expect(axios).toHaveBeenCalledWith({ + method, + url, + data, + headers: mockNoAuthHeaders, + }); }); - }); - it('should make a request with the correct parameters and default data', async () => { - const data = {}; - await apiRequest(url, method); + it('should make a request with the correct parameters and default data', async () => { + const data = {}; + await apiRequest(url, method); - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, + expect(axios).toHaveBeenCalledWith({ + method, + url, + data, + headers: mockNoAuthHeaders, + }); }); }); -}); -describe('apiRequestAuth', () => { - const token = 'yourAuthToken' as Token; - - afterEach(() => { - jest.clearAllMocks(); - }); + describe('apiRequestAuth', () => { + afterEach(() => { + jest.clearAllMocks(); + }); - it('should make an authenticated request with the correct parameters', async () => { - const data = { key: 'value' }; + it('should make an authenticated request with the correct parameters', async () => { + const data = { key: 'value' }; - await apiRequestAuth(url, method, token, data); + await apiRequestAuth(url, method, mockToken, data); - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockAuthHeaders, + expect(axios).toHaveBeenCalledWith({ + method, + url, + data, + headers: mockAuthHeaders, + }); }); - }); - it('should make an authenticated request with the correct parameters and default data', async () => { - const data = {}; + it('should make an authenticated request with the correct parameters and default data', async () => { + const data = {}; - await apiRequestAuth(url, method, token); + await apiRequestAuth(url, method, mockToken); - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockAuthHeaders, + expect(axios).toHaveBeenCalledWith({ + method, + url, + data, + headers: mockAuthHeaders, + }); }); }); - it('shouldRequestWithNoCache', () => { - expect( - shouldRequestWithNoCache('https://example.com/api/v3/notifications'), - ).toBe(true); - - expect( - shouldRequestWithNoCache('https://example.com/login/oauth/access_token'), - ).toBe(true); + describe('performGraphQLRequest', () => { + it('should performGraphQLRequest with the correct parameters and default data', async () => { + (axios as unknown as jest.Mock).mockResolvedValue({ + data: { data: {}, errors: [] }, + headers: {}, + }); + const expectedData = { + query: FetchAuthenticatedUserDetailsDocument, + variables: undefined, + }; + + await performGraphQLRequest( + url, + mockToken, + FetchAuthenticatedUserDetailsDocument, + ); + + expect(axios).toHaveBeenCalledWith({ + method: 'POST', + url, + data: expectedData, + headers: mockAuthHeaders, + }); + }); + }); - expect(shouldRequestWithNoCache('https://example.com/notifications')).toBe( - true, - ); + describe('performGraphQLRequestString', () => { + it('should performGraphQLRequestString with the correct parameters and default data', async () => { + (axios as unknown as jest.Mock).mockResolvedValue({ + data: { data: {}, errors: [] }, + headers: {}, + }); + const queryString = 'query Foo { repository { issue { title } } }'; + const expectedData = { + query: queryString, + variables: undefined, + }; + + await performGraphQLRequestString(url, mockToken, queryString); + + expect(axios).toHaveBeenCalledWith({ + method: 'POST', + url, + data: expectedData, + headers: mockAuthHeaders, + }); + }); + }); - expect( - shouldRequestWithNoCache('https://example.com/some/other/endpoint'), - ).toBe(false); + describe('shouldRequestWithNoCache', () => { + it('shouldRequestWithNoCache', () => { + expect( + shouldRequestWithNoCache('https://example.com/api/v3/notifications'), + ).toBe(true); + + expect( + shouldRequestWithNoCache( + 'https://example.com/login/oauth/access_token', + ), + ).toBe(true); + + expect( + shouldRequestWithNoCache('https://example.com/notifications'), + ).toBe(true); + + expect( + shouldRequestWithNoCache('https://example.com/some/other/endpoint'), + ).toBe(false); + }); }); - it('should get headers correctly', async () => { - expect(await getHeaders(url)).toEqual(mockNoAuthHeaders); + describe('getHeaders', () => { + it('should get headers correctly', async () => { + expect(await getHeaders(url)).toEqual(mockNoAuthHeaders); - expect(await getHeaders(url, token)).toEqual(mockAuthHeaders); + expect(await getHeaders(url, mockToken)).toEqual(mockAuthHeaders); - expect( - await getHeaders( - 'https://example.com/api/v3/notifications' as Link, - token, - ), - ).toEqual(mockNonCachedAuthHeaders); + expect( + await getHeaders( + 'https://example.com/api/v3/notifications' as Link, + mockToken, + ), + ).toEqual(mockNonCachedAuthHeaders); + }); }); }); diff --git a/src/renderer/utils/api/request.ts b/src/renderer/utils/api/request.ts index 035e2e396..201f4fe2a 100644 --- a/src/renderer/utils/api/request.ts +++ b/src/renderer/utils/api/request.ts @@ -121,6 +121,35 @@ export async function performGraphQLRequest( }) as Promise>; } +/** + * Perform a GraphQL API request using a raw query string instead of a TypedDocumentString. + * + * Useful for dynamically composed queries (e.g., merged queries built at runtime). + */ +export async function performGraphQLRequestString( + url: Link, + token: Token, + query: string, + variables?: Record, +): Promise> { + const headers = await getHeaders(url, token); + + return axios({ + method: 'POST', + url, + data: { + query, + variables, + }, + headers: headers, + }).then((response) => { + return { + ...response.data, + headers: response.headers, + } as ExecutionResultWithHeaders; + }); +} + /** * Return true if the request should be made with no-cache * diff --git a/src/renderer/utils/links.test.ts b/src/renderer/utils/links.test.ts index fba2a9d60..71b3957dc 100644 --- a/src/renderer/utils/links.test.ts +++ b/src/renderer/utils/links.test.ts @@ -1,12 +1,7 @@ import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; -import { createMockNotificationUser } from '../__mocks__/user-mocks'; +import { createMockGitifyNotificationUser } from '../__mocks__/user-mocks'; import { Constants } from '../constants'; -import type { - GitifyNotificationUser, - GitifyRepository, - Hostname, - Link, -} from '../types'; +import type { GitifyRepository, Hostname, Link } from '../types'; import { mockSingleNotification } from './api/__mocks__/response-mocks'; import * as authUtils from './auth/utils'; import * as comms from './comms'; @@ -75,9 +70,7 @@ describe('renderer/utils/links.ts', () => { }); it('openUserProfile', () => { - const mockUser = createMockNotificationUser( - 'mock-user', - ) as GitifyNotificationUser; + const mockUser = createMockGitifyNotificationUser('mock-user'); openUserProfile(mockUser); diff --git a/src/renderer/utils/notifications/filters/search.test.ts b/src/renderer/utils/notifications/filters/search.test.ts index 5bbfe941c..8e70afb6e 100644 --- a/src/renderer/utils/notifications/filters/search.test.ts +++ b/src/renderer/utils/notifications/filters/search.test.ts @@ -21,7 +21,7 @@ describe('renderer/utils/notifications/filters/search.ts', () => { it('matches each known qualifier by its exact prefix and additional value', () => { for (const q of ALL_SEARCH_QUALIFIERS) { - const token = q.prefix + 'someValue'; + const token = `${q.prefix}someValue`; const parsed = parseSearchInput(token); expect(parsed).not.toBeNull(); expect(parsed?.qualifier).toBe(q); diff --git a/src/renderer/utils/notifications/handlers/default.test.ts b/src/renderer/utils/notifications/handlers/default.test.ts index 346f63199..62fa8adcc 100644 --- a/src/renderer/utils/notifications/handlers/default.test.ts +++ b/src/renderer/utils/notifications/handlers/default.test.ts @@ -12,6 +12,12 @@ import { import { defaultHandler } from './default'; describe('renderer/utils/notifications/handlers/default.ts', () => { + describe('supportsMergedQueryEnrichment', () => { + it('should not support merge query', () => { + expect(defaultHandler.supportsMergedQueryEnrichment).toBeFalsy(); + }); + }); + describe('enrich', () => { it('unhandled subject details', async () => { const mockNotification = createPartialMockNotification({ diff --git a/src/renderer/utils/notifications/handlers/default.ts b/src/renderer/utils/notifications/handlers/default.ts index df542ffd8..ce978a7f5 100644 --- a/src/renderer/utils/notifications/handlers/default.ts +++ b/src/renderer/utils/notifications/handlers/default.ts @@ -17,6 +17,8 @@ import { formatForDisplay } from './utils'; export class DefaultHandler implements NotificationTypeHandler { type?: SubjectType; + supportsMergedQueryEnrichment?: boolean = false; + async enrich( _notification: GitifyNotification, _settings: SettingsState, diff --git a/src/renderer/utils/notifications/handlers/discussion.test.ts b/src/renderer/utils/notifications/handlers/discussion.test.ts index e220612b6..b004ab456 100644 --- a/src/renderer/utils/notifications/handlers/discussion.test.ts +++ b/src/renderer/utils/notifications/handlers/discussion.test.ts @@ -6,7 +6,7 @@ import { createPartialMockNotification, } from '../../../__mocks__/notifications-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; -import { createMockNotificationUser } from '../../../__mocks__/user-mocks'; +import { createMockGraphQLAuthor } from '../../../__mocks__/user-mocks'; import type { GitifyNotification } from '../../../types'; import { type GitifyDiscussionState, @@ -20,11 +20,17 @@ import type { } from '../../api/graphql/generated/graphql'; import { discussionHandler } from './discussion'; -const mockAuthor = createMockNotificationUser('discussion-author'); -const mockCommenter = createMockNotificationUser('discussion-commenter'); -const mockReplier = createMockNotificationUser('discussion-replier'); +const mockAuthor = createMockGraphQLAuthor('discussion-author'); +const mockCommenter = createMockGraphQLAuthor('discussion-commenter'); +const mockReplier = createMockGraphQLAuthor('discussion-replier'); describe('renderer/utils/notifications/handlers/discussion.ts', () => { + describe('supportsMergedQueryEnrichment', () => { + it('should support merge query', () => { + expect(discussionHandler.supportsMergedQueryEnrichment).toBeTruthy(); + }); + }); + describe('enrich', () => { const mockNotification = createPartialMockNotification({ title: 'This is a mock discussion', @@ -63,8 +69,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'ANSWERED', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, @@ -97,8 +103,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, @@ -134,8 +140,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'DUPLICATE', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, @@ -175,8 +181,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'ANSWERED', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, @@ -223,8 +229,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'ANSWERED', user: { login: mockCommenter.login, - htmlUrl: mockCommenter.htmlUrl, avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, type: mockCommenter.type, }, comments: 1, @@ -277,8 +283,8 @@ describe('renderer/utils/notifications/handlers/discussion.ts', () => { state: 'ANSWERED', user: { login: mockReplier.login, - htmlUrl: mockReplier.htmlUrl, avatarUrl: mockReplier.avatarUrl, + htmlUrl: mockReplier.htmlUrl, type: mockReplier.type, }, comments: 1, diff --git a/src/renderer/utils/notifications/handlers/discussion.ts b/src/renderer/utils/notifications/handlers/discussion.ts index f7290dc56..a7f5669fb 100644 --- a/src/renderer/utils/notifications/handlers/discussion.ts +++ b/src/renderer/utils/notifications/handlers/discussion.ts @@ -22,6 +22,7 @@ import { fetchDiscussionByNumber } from '../../api/client'; import type { CommentFieldsFragment, DiscussionCommentFieldsFragment, + DiscussionDetailsFragment, } from '../../api/graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; import { getNotificationAuthor } from './utils'; @@ -29,12 +30,16 @@ import { getNotificationAuthor } from './utils'; class DiscussionHandler extends DefaultHandler { readonly type = 'Discussion'; + readonly supportsMergedQueryEnrichment = true; + async enrich( notification: GitifyNotification, _settings: SettingsState, + fetchedData?: DiscussionDetailsFragment, ): Promise> { - const response = await fetchDiscussionByNumber(notification); - const discussion = response.data.repository?.discussion; + const discussion = + fetchedData ?? + (await fetchDiscussionByNumber(notification)).data.repository?.discussion; let discussionState: GitifyDiscussionState = 'OPEN'; diff --git a/src/renderer/utils/notifications/handlers/issue.test.ts b/src/renderer/utils/notifications/handlers/issue.test.ts index 24c5d7883..3360b0e56 100644 --- a/src/renderer/utils/notifications/handlers/issue.test.ts +++ b/src/renderer/utils/notifications/handlers/issue.test.ts @@ -6,7 +6,7 @@ import { createPartialMockNotification, } from '../../../__mocks__/notifications-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; -import { createMockNotificationUser } from '../../../__mocks__/user-mocks'; +import { createMockGraphQLAuthor } from '../../../__mocks__/user-mocks'; import type { GitifyNotification } from '../../../types'; import { type GitifyIssueState, @@ -21,10 +21,16 @@ import type { } from '../../api/graphql/generated/graphql'; import { issueHandler } from './issue'; -const mockAuthor = createMockNotificationUser('issue-author'); -const mockCommenter = createMockNotificationUser('issue-commenter'); +const mockAuthor = createMockGraphQLAuthor('issue-author'); +const mockCommenter = createMockGraphQLAuthor('issue-commenter'); describe('renderer/utils/notifications/handlers/issue.ts', () => { + describe('supportsMergedQueryEnrichment', () => { + it('should support merge query', () => { + expect(issueHandler.supportsMergedQueryEnrichment).toBeTruthy(); + }); + }); + describe('enrich', () => { let mockNotification: GitifyNotification; @@ -64,14 +70,14 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123', labels: [], - milestone: null, + milestone: undefined, } as Partial); }); @@ -98,14 +104,14 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { state: 'COMPLETED', user: { login: mockAuthor.login, + avatarUrl: mockCommenter.avatarUrl, htmlUrl: mockAuthor.htmlUrl, - avatarUrl: mockAuthor.avatarUrl, type: mockAuthor.type, }, comments: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123', labels: [], - milestone: null, + milestone: undefined, } as Partial); }); @@ -140,15 +146,15 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { state: 'OPEN', user: { login: mockCommenter.login, - htmlUrl: mockCommenter.htmlUrl, avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, type: mockCommenter.type, }, comments: 1, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123#issuecomment-1234', labels: [], - milestone: null, + milestone: undefined, } as Partial); }); @@ -177,14 +183,14 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123', labels: ['enhancement'], - milestone: null, + milestone: undefined, } as Partial); }); @@ -214,8 +220,8 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, comments: 0, diff --git a/src/renderer/utils/notifications/handlers/issue.ts b/src/renderer/utils/notifications/handlers/issue.ts index 13de90dd2..680fb99b0 100644 --- a/src/renderer/utils/notifications/handlers/issue.ts +++ b/src/renderer/utils/notifications/handlers/issue.ts @@ -17,22 +17,27 @@ import type { } from '../../../types'; import { IconColor } from '../../../types'; import { fetchIssueByNumber } from '../../api/client'; +import type { IssueDetailsFragment } from '../../api/graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; import { getNotificationAuthor } from './utils'; class IssueHandler extends DefaultHandler { readonly type = 'Issue'; + readonly supportsMergedQueryEnrichment = true; + async enrich( notification: GitifyNotification, _settings: SettingsState, + fetchedData?: IssueDetailsFragment, ): Promise> { - const response = await fetchIssueByNumber(notification); - const issue = response.data.repository?.issue; + const issue = + fetchedData ?? + (await fetchIssueByNumber(notification)).data.repository?.issue; const issueState = issue.stateReason ?? issue.state; - const issueComment = issue.comments.nodes[0]; + const issueComment = issue.comments?.nodes?.[0]; const issueUser = getNotificationAuthor([ issueComment?.author, @@ -44,8 +49,8 @@ class IssueHandler extends DefaultHandler { state: issueState, user: issueUser, comments: issue.comments.totalCount, - labels: issue.labels?.nodes.map((label) => label.name), - milestone: issue.milestone, + labels: issue.labels?.nodes.map((label) => label.name) ?? [], + milestone: issue.milestone ?? undefined, htmlUrl: issueComment?.url ?? issue.url, }; } diff --git a/src/renderer/utils/notifications/handlers/pullRequest.test.ts b/src/renderer/utils/notifications/handlers/pullRequest.test.ts index d34d1740e..908ea6dcb 100644 --- a/src/renderer/utils/notifications/handlers/pullRequest.test.ts +++ b/src/renderer/utils/notifications/handlers/pullRequest.test.ts @@ -6,7 +6,7 @@ import { createPartialMockNotification, } from '../../../__mocks__/notifications-mocks'; import { mockSettings } from '../../../__mocks__/state-mocks'; -import { createMockNotificationUser } from '../../../__mocks__/user-mocks'; +import { createMockGraphQLAuthor } from '../../../__mocks__/user-mocks'; import type { GitifyNotification } from '../../../types'; import { type GitifyPullRequestState, @@ -21,8 +21,8 @@ import type { } from '../../api/graphql/generated/graphql'; import { getLatestReviewForReviewers, pullRequestHandler } from './pullRequest'; -const mockAuthor = createMockNotificationUser('some-author'); -const mockCommenter = createMockNotificationUser('some-commenter'); +const mockAuthor = createMockGraphQLAuthor('some-author'); +const mockCommenter = createMockGraphQLAuthor('some-commenter'); describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { let mockNotification: GitifyNotification; @@ -37,6 +37,14 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { }); }); + describe('mergeQueryConfig', () => { + describe('supportsMergedQueryEnrichment', () => { + it('should support merge query', () => { + expect(pullRequestHandler.supportsMergedQueryEnrichment).toBeTruthy(); + }); + }); + }); + describe('enrich', () => { beforeEach(() => { // axios will default to using the XHR adapter which can't be intercepted @@ -67,8 +75,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'CLOSED', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -106,8 +114,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'DRAFT', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -145,8 +153,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'MERGE_QUEUE', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -184,8 +192,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'MERGED', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -231,8 +239,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'OPEN', user: { login: mockCommenter.login, - htmlUrl: mockCommenter.htmlUrl, avatarUrl: mockCommenter.avatarUrl, + htmlUrl: mockCommenter.htmlUrl, type: mockCommenter.type, }, reviews: null, @@ -277,8 +285,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -322,8 +330,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, @@ -364,8 +372,8 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { state: 'OPEN', user: { login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }, reviews: null, diff --git a/src/renderer/utils/notifications/handlers/pullRequest.ts b/src/renderer/utils/notifications/handlers/pullRequest.ts index 5fc237f16..290255a1b 100644 --- a/src/renderer/utils/notifications/handlers/pullRequest.ts +++ b/src/renderer/utils/notifications/handlers/pullRequest.ts @@ -19,19 +19,26 @@ import { type SettingsState, } from '../../../types'; import { fetchPullByNumber } from '../../api/client'; -import type { PullRequestReviewFieldsFragment } from '../../api/graphql/generated/graphql'; +import type { + PullRequestDetailsFragment, + PullRequestReviewFieldsFragment, +} from '../../api/graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; import { getNotificationAuthor } from './utils'; class PullRequestHandler extends DefaultHandler { readonly type = 'PullRequest' as const; + readonly supportsMergedQueryEnrichment = true; + async enrich( notification: GitifyNotification, _settings: SettingsState, + fetchedData?: PullRequestDetailsFragment, ): Promise> { - const response = await fetchPullByNumber(notification); - const pr = response.data.repository.pullRequest; + const pr = + fetchedData ?? + (await fetchPullByNumber(notification)).data.repository?.pullRequest; let prState: GitifyPullRequestState = pr.state; if (pr.isDraft) { @@ -52,7 +59,7 @@ class PullRequestHandler extends DefaultHandler { user: prUser, reviews: reviews, comments: pr.comments.totalCount, - labels: pr.labels?.nodes.map((label) => label.name), + labels: pr.labels?.nodes.map((label) => label.name) ?? [], linkedIssues: pr.closingIssuesReferences?.nodes.map( (issue) => `#${issue.number}`, ), @@ -108,7 +115,7 @@ export function getLatestReviewForReviewers( } // Find the most recent review for each reviewer - const latestReviews = []; + const latestReviews: PullRequestReviewFieldsFragment[] = []; const sortedReviews = reviews.toReversed(); for (const prReview of sortedReviews) { const reviewerFound = latestReviews.find( diff --git a/src/renderer/utils/notifications/handlers/types.ts b/src/renderer/utils/notifications/handlers/types.ts index dd8477085..4a705255d 100644 --- a/src/renderer/utils/notifications/handlers/types.ts +++ b/src/renderer/utils/notifications/handlers/types.ts @@ -14,11 +14,21 @@ export interface NotificationTypeHandler { readonly type?: SubjectType; /** - * Enrich a notification. Settings may be unused for some handlers. + * Whether the notification handler supports enrichment via merged GraphQL query. + */ + readonly supportsMergedQueryEnrichment?: boolean; + + /** + * Enriches a base notification with additional information (state, author, metrics, etc). + * + * @param notification The base notification being enriched + * @param settings The app settings, which for some handlers may not be used during enrichment. + * @param fetchedData Previously fetched enrichment data (upstream). If present, then enrich will skip fetching detailed data inline. */ enrich( notification: GitifyNotification, settings: SettingsState, + fetchedData?: unknown, ): Promise>; /** diff --git a/src/renderer/utils/notifications/handlers/utils.test.ts b/src/renderer/utils/notifications/handlers/utils.test.ts index 478d0cba0..3d4e98d66 100644 --- a/src/renderer/utils/notifications/handlers/utils.test.ts +++ b/src/renderer/utils/notifications/handlers/utils.test.ts @@ -1,9 +1,9 @@ -import { createMockNotificationUser } from '../../../__mocks__/user-mocks'; +import { createMockGraphQLAuthor } from '../../../__mocks__/user-mocks'; import { formatForDisplay, getNotificationAuthor } from './utils'; describe('renderer/utils/notifications/handlers/utils.ts', () => { describe('getNotificationAuthor', () => { - const mockAuthor = createMockNotificationUser('some-author'); + const mockAuthor = createMockGraphQLAuthor('some-author'); it('returns null when all users are null', () => { const result = getNotificationAuthor([null, null]); @@ -16,8 +16,8 @@ describe('renderer/utils/notifications/handlers/utils.ts', () => { expect(result).toEqual({ login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }); }); @@ -27,8 +27,8 @@ describe('renderer/utils/notifications/handlers/utils.ts', () => { expect(result).toEqual({ login: mockAuthor.login, - htmlUrl: mockAuthor.htmlUrl, avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, type: mockAuthor.type, }); }); diff --git a/src/renderer/utils/notifications/handlers/utils.ts b/src/renderer/utils/notifications/handlers/utils.ts index e46da63e2..ce1aa0116 100644 --- a/src/renderer/utils/notifications/handlers/utils.ts +++ b/src/renderer/utils/notifications/handlers/utils.ts @@ -1,4 +1,12 @@ import type { GitifyNotificationUser } from '../../../types'; +import type { AuthorFieldsFragment } from '../../api/graphql/generated/graphql'; + +// Author type from GraphQL or manually constructed +type AuthorInput = + | AuthorFieldsFragment + | GitifyNotificationUser + | null + | undefined; /** * Construct the notification subject user based on an order prioritized list of users @@ -6,7 +14,7 @@ import type { GitifyNotificationUser } from '../../../types'; * @returns the subject user */ export function getNotificationAuthor( - users: GitifyNotificationUser[], + users: AuthorInput[], ): GitifyNotificationUser { let subjectUser: GitifyNotificationUser = null; @@ -14,8 +22,8 @@ export function getNotificationAuthor( if (user) { subjectUser = { login: user.login, - htmlUrl: user.htmlUrl, avatarUrl: user.avatarUrl, + htmlUrl: user.htmlUrl, type: user.type, }; diff --git a/src/renderer/utils/notifications/notifications.test.ts b/src/renderer/utils/notifications/notifications.test.ts index 6fd71e952..548b448b0 100644 --- a/src/renderer/utils/notifications/notifications.test.ts +++ b/src/renderer/utils/notifications/notifications.test.ts @@ -13,6 +13,7 @@ import { import { mockSettings } from '../../__mocks__/state-mocks'; import { type AccountNotifications, + type GitifyNotification, type GitifyRepository, GroupBy, type Link, @@ -21,6 +22,7 @@ import { import * as logger from '../../utils/logger'; import { enrichNotification, + enrichNotifications, getNotificationCount, getUnreadNotificationCount, stabilizeNotificationsOrder, @@ -141,4 +143,51 @@ describe('renderer/utils/notifications/notifications.ts', () => { ).toEqual([0, 2, 1, 3, 5, 4]); }); }); + + describe('enrichNotifications', () => { + it('should skip enrichment when detailedNotifications is false', async () => { + const notification = createPartialMockNotification({ + title: 'Issue #1', + type: 'Issue', + url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link, + }) as GitifyNotification; + const settings: SettingsState = { + ...mockSettings, + detailedNotifications: false, + }; + + const result = await enrichNotifications([notification], settings); + + expect(result).toEqual([notification]); + }); + + it('should return notifications when all types do not support merge query', async () => { + // CheckSuite types don't support merge query and have no URL + const notification = createPartialMockNotification({ + title: 'CI workflow run', + type: 'CheckSuite', + url: null, + }) as GitifyNotification; + const settings: SettingsState = { + ...mockSettings, + detailedNotifications: true, + }; + + const result = await enrichNotifications([notification], settings); + + expect(result).toHaveLength(1); + expect(result[0].subject.title).toBe('CI workflow run'); + }); + + it('should handle empty notifications array', async () => { + const settings: SettingsState = { + ...mockSettings, + detailedNotifications: true, + }; + + const result = await enrichNotifications([], settings); + + expect(result).toEqual([]); + }); + }); }); diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index e242157cd..223b5d3eb 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -5,8 +5,12 @@ import type { GitifySubject, SettingsState, } from '../../types'; -import { listNotificationsForAuthenticatedUser } from '../api/client'; +import { + fetchNotificationDetailsForList, + listNotificationsForAuthenticatedUser, +} from '../api/client'; import { determineFailureType } from '../api/errors'; +import type { FetchMergedDetailsTemplateQuery } from '../api/graphql/generated/graphql'; import { transformNotification } from '../api/transform'; import { rendererLogError, rendererLogWarn } from '../logger'; import { @@ -129,12 +133,28 @@ export async function enrichNotifications( return notifications; } + // Build and fetch merged details via client; returns per-notification results + let mergedResults: Map< + GitifyNotification, + FetchMergedDetailsTemplateQuery['repository'] + > = new Map(); + try { + mergedResults = await fetchNotificationDetailsForList(notifications); + } catch (err) { + rendererLogError( + 'enrichNotifications', + 'Failed to fetch merged notification details', + err, + ); + } + const enrichedNotifications = await Promise.all( notifications.map(async (notification: GitifyNotification) => { - return enrichNotification(notification, settings); + const fragment = mergedResults.get(notification); + + return enrichNotification(notification, settings, fragment); }), ); - return enrichedNotifications; } @@ -148,12 +168,17 @@ export async function enrichNotifications( export async function enrichNotification( notification: GitifyNotification, settings: SettingsState, + fetchedData?: unknown, ): Promise { let additionalSubjectDetails: Partial = {}; try { const handler = createNotificationHandler(notification); - additionalSubjectDetails = await handler.enrich(notification, settings); + additionalSubjectDetails = await handler.enrich( + notification, + settings, + fetchedData, + ); } catch (err) { rendererLogError( 'enrichNotification',