Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/renderer/utils/forges/github/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {

import {
githubCapabilities,
getGitHubCapabilities,
supportsAnsweredDiscussion,
supportsStackedPullRequests,
} from './capabilities';
Expand Down Expand Up @@ -94,4 +95,20 @@ describe('renderer/utils/forges/github/capabilities.ts', () => {
).toBe(false);
});
});

describe('getGitHubCapabilities', () => {
it('enables all gated capabilities for GitHub Cloud', () => {
expect(getGitHubCapabilities(mockGitHubCloudAccount)).toEqual({
stackedPullRequests: true,
answeredDiscussion: true,
});
});

it('disables gated capabilities for GitHub Enterprise Server', () => {
expect(getGitHubCapabilities(mockGitHubEnterpriseServerAccount)).toEqual({
stackedPullRequests: false,
answeredDiscussion: false,
});
});
});
});
22 changes: 22 additions & 0 deletions src/renderer/utils/forges/github/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,25 @@ export function supportsAnsweredDiscussion(account: Account): boolean {
export function supportsStackedPullRequests(account: Account): boolean {
return isGitHubCloudHost(account.hostname);
}

/**
* The set of capabilities that gate GraphQL field selections via the custom
* `@gated(requires: ...)` directive. The keys must match the `requires`
* argument used in the GraphQL documents.
*/
export type GitHubGatedCapabilities = {
stackedPullRequests: boolean;
answeredDiscussion: boolean;
};

/**
* Resolve the gated-field capabilities for an account. Consumed by the query
* sanitizer in `graphql/utils.ts` to strip `@gated` selections that the
* account's GitHub platform/version does not support.
*/
export function getGitHubCapabilities(account: Account): GitHubGatedCapabilities {
return {
stackedPullRequests: supportsStackedPullRequests(account),
answeredDiscussion: supportsAnsweredDiscussion(account),
};
}
150 changes: 113 additions & 37 deletions src/renderer/utils/forges/github/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ExecutionResult } from 'graphql';

import { mockGitHubCloudAccount } from '../../../__mocks__/account-mocks';
import {
mockGitHubCloudAccount,
mockGitHubEnterpriseServerAccount,
} from '../../../__mocks__/account-mocks';
import {
mockGitHubCloudGitifyNotifications,
mockPartialGitifyNotification,
Expand Down Expand Up @@ -31,11 +34,9 @@ import {
markNotificationThreadAsRead,
} from './client';
import {
FetchDiscussionByNumberDocument,
type FetchDiscussionByNumberQuery,
FetchIssueByNumberDocument,
type FetchDiscussionByNumberQuery,
type FetchIssueByNumberQuery,
FetchPullRequestByNumberDocument,
type FetchPullRequestByNumberQuery,
} from './graphql/generated/graphql';
import type { OctokitClient } from './octokit';
Expand Down Expand Up @@ -312,32 +313,56 @@ describe('renderer/utils/forges/github/client.ts', () => {
});
});

it('fetchDiscussionByNumber calls performGraphQLRequest with correct args', async () => {
const performGraphQLRequestSpy = vi.mocked(apiRequests.performGraphQLRequest);
it('fetchDiscussionByNumber calls performGraphQLRequestString with sanitized query', async () => {
const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString);

const mockNotification = mockPartialGitifyNotification({
title: 'Some discussion',
url: 'https://api.github.com/repos/gitify-app/gitify/discussion/123' as Link,
type: 'Discussion',
});

performGraphQLRequestSpy.mockResolvedValue({} as ExecutionResult<FetchDiscussionByNumberQuery>);
performGraphQLRequestStringSpy.mockResolvedValue(
{} as ExecutionResult<FetchDiscussionByNumberQuery>,
);

await fetchDiscussionByNumber(mockNotification);

expect(performGraphQLRequestSpy).toHaveBeenCalledWith(
mockNotification.account,
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,
},
const [account, query, variables] = performGraphQLRequestStringSpy.mock.calls[0];
expect(account).toBe(mockNotification.account);
expect(query).toContain('isAnswered');
expect(query).not.toContain('@gated');
expect(query).toContain('query FetchDiscussionByNumber');
expect(variables).toEqual({
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,
});
});

it('fetchDiscussionByNumber strips isAnswered for GitHub Enterprise Server accounts', async () => {
const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString);

const mockNotification = mockPartialGitifyNotification({
title: 'Some discussion',
url: 'https://github.gitify.io/api/v3/repos/gitify-app/gitify/discussion/123' as Link,
type: 'Discussion',
});
mockNotification.account = mockGitHubEnterpriseServerAccount;

performGraphQLRequestStringSpy.mockResolvedValue(
{} as ExecutionResult<FetchDiscussionByNumberQuery>,
);

await fetchDiscussionByNumber(mockNotification);

const [account, query] = performGraphQLRequestStringSpy.mock.calls[0];
expect(account).toBe(mockGitHubEnterpriseServerAccount);
expect(query).not.toContain('isAnswered');
expect(query).not.toContain('@gated');
});

it('fetchIssueByNumber calls performGraphQLRequest with correct args', async () => {
Expand Down Expand Up @@ -366,35 +391,58 @@ describe('renderer/utils/forges/github/client.ts', () => {
);
});

it('fetchPullByNumber calls performGraphQLRequest with correct args', async () => {
const performGraphQLRequestSpy = vi.mocked(apiRequests.performGraphQLRequest);
it('fetchPullByNumber calls performGraphQLRequestString with sanitized query', async () => {
const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString);

const mockNotification = mockPartialGitifyNotification({
title: 'Some pull request',
url: 'https://api.github.com/repos/gitify-app/gitify/pulls/123' as Link,
type: 'PullRequest',
});

performGraphQLRequestSpy.mockResolvedValue(
performGraphQLRequestStringSpy.mockResolvedValue(
{} as ExecutionResult<FetchPullRequestByNumberQuery>,
);

await fetchPullByNumber(mockNotification);

expect(performGraphQLRequestSpy).toHaveBeenCalledWith(
mockNotification.account,
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,
includeStackEntry: true,
},
const [account, query, variables] = performGraphQLRequestStringSpy.mock.calls[0];
expect(account).toBe(mockNotification.account);
expect(query).toContain('stackEntry');
expect(query).not.toContain('@gated');
expect(query).toContain('query FetchPullRequestByNumber');
expect(variables).toEqual({
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,
});
});

it('fetchPullByNumber strips stackEntry for GitHub Enterprise Server accounts', async () => {
const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString);

const mockNotification = mockPartialGitifyNotification({
title: 'Some pull request',
url: 'https://github.gitify.io/api/v3/repos/gitify-app/gitify/pulls/123' as Link,
type: 'PullRequest',
});
mockNotification.account = mockGitHubEnterpriseServerAccount;

performGraphQLRequestStringSpy.mockResolvedValue(
{} as ExecutionResult<FetchPullRequestByNumberQuery>,
);

await fetchPullByNumber(mockNotification);

const [account, query] = performGraphQLRequestStringSpy.mock.calls[0];
expect(account).toBe(mockGitHubEnterpriseServerAccount);
expect(query).not.toContain('stackEntry');
expect(query).not.toContain('@gated');
expect(query).toContain('query FetchPullRequestByNumber');
});

describe('fetchNotificationDetailsForList', () => {
Expand Down Expand Up @@ -440,8 +488,6 @@ describe('renderer/utils/forges/github/client.ts', () => {
{
firstClosingIssues: 100,
firstLabels: 100,
includeIsAnswered: true,
includeStackEntry: true,
isDiscussionNotification0: false,
isDiscussionNotification1: false,
isIssueNotification0: true,
Expand All @@ -460,6 +506,36 @@ describe('renderer/utils/forges/github/client.ts', () => {
owner1: 'gitify-app',
},
);

const query = performGraphQLRequestStringSpy.mock.calls[0][1];
expect(query).toContain('stackEntry');
expect(query).toContain('isAnswered');
expect(query).not.toContain('@gated');
});

it('fetchNotificationDetailsForList strips gated fields for GitHub Enterprise Server accounts', async () => {
const performGraphQLRequestStringSpy = vi.mocked(apiRequests.performGraphQLRequestString);

const notifications = mockGitHubCloudGitifyNotifications.map((notification) => ({
...notification,
account: mockGitHubEnterpriseServerAccount,
}));

performGraphQLRequestStringSpy.mockResolvedValue({
data: {},
headers: {},
} as ExecutionResult<unknown>);

await fetchNotificationDetailsForList(notifications);

const [account, query, variables] = performGraphQLRequestStringSpy.mock.calls[0];
expect(account).toBe(mockGitHubEnterpriseServerAccount);
expect(query).not.toContain('stackEntry');
expect(query).not.toContain('isAnswered');
expect(query).not.toContain('@gated');
expect(query).toContain('FetchMergedNotifications');
expect(variables).not.toHaveProperty('includeStackEntry');
expect(variables).not.toHaveProperty('includeIsAnswered');
});
});
});
26 changes: 18 additions & 8 deletions src/renderer/utils/forges/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
} from './types';

import { reportServerPollInterval } from '../../notifications/pollInterval';
import { supportsAnsweredDiscussion, supportsStackedPullRequests } from './capabilities';
import { getGitHubCapabilities } from './capabilities';
import {
FetchDiscussionByNumberDocument,
type FetchDiscussionByNumberQuery,
Expand All @@ -25,6 +25,7 @@ import {
type FetchPullRequestByNumberQuery,
} from './graphql/generated/graphql';
import { MergeQueryBuilder } from './graphql/MergeQueryBuilder';
import { stripGatedSelections } from './graphql/utils';
import { createNotificationHandler } from './handlers';
import { createOctokitClient, createOctokitClientUncached } from './octokit';
import { performGraphQLRequest, performGraphQLRequestString } from './request';
Expand Down Expand Up @@ -206,14 +207,18 @@ export async function fetchDiscussionByNumber(
): Promise<FetchDiscussionByNumberQuery> {
const number = getNumberFromUrl(notification.subject.url!);

return performGraphQLRequest(notification.account, FetchDiscussionByNumberDocument, {
const query = stripGatedSelections(
FetchDiscussionByNumberDocument.toString(),
getGitHubCapabilities(notification.account),
);

return performGraphQLRequestString<FetchDiscussionByNumberQuery>(notification.account, query, {
owner: notification.repository.owner.login,
name: notification.repository.name,
number: number,
firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS,
lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS,
lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES,
includeIsAnswered: supportsAnsweredDiscussion(notification.account),
});
}

Expand Down Expand Up @@ -242,15 +247,19 @@ export async function fetchPullByNumber(
): Promise<FetchPullRequestByNumberQuery> {
const number = getNumberFromUrl(notification.subject.url!);

return performGraphQLRequest(notification.account, FetchPullRequestByNumberDocument, {
const query = stripGatedSelections(
FetchPullRequestByNumberDocument.toString(),
getGitHubCapabilities(notification.account),
);

return performGraphQLRequestString<FetchPullRequestByNumberQuery>(notification.account, query, {
owner: notification.repository.owner.login,
name: notification.repository.name,
number: number,
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,
includeStackEntry: supportsStackedPullRequests(notification.account),
});
} /**
* Fetch notification details for supported types (ie: Discussions, Issues and Pull Requests).
Expand Down Expand Up @@ -297,8 +306,6 @@ export async function fetchNotificationDetailsForList(
}

builder.setSharedVariables({
includeIsAnswered: supportsAnsweredDiscussion(notifications[0].account),
includeStackEntry: supportsStackedPullRequests(notifications[0].account),
firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES,
firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS,
lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS,
Expand All @@ -307,7 +314,10 @@ export async function fetchNotificationDetailsForList(
lastReviews: Constants.GRAPHQL_ARGS.LAST_REVIEWS,
});

const query = builder.getGraphQLQuery();
const query = stripGatedSelections(
builder.getGraphQLQuery(),
getGitHubCapabilities(notifications[0].account),
);
const variables = builder.getGraphQLVariables();

const response = await performGraphQLRequestString(notifications[0].account, query, variables);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => {
lastReviews: 4,
firstLabels: 10,
firstClosingIssues: 8,
includeIsAnswered: true,
includeStackEntry: true,
};

const nodeVarsA: FetchBatchMergedTemplateIndexedBaseVariables = {
Expand Down Expand Up @@ -51,8 +49,6 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => {
expect(query).toContain('$lastReviews: Int');
expect(query).toContain('$firstLabels: Int');
expect(query).toContain('$firstClosingIssues: Int');
expect(query).toContain('$includeIsAnswered: Boolean!');
expect(query).toContain('$includeStackEntry: Boolean!');

expect(query).toContain('$owner0: String!');
expect(query).toContain('$name0: String!');
Expand Down Expand Up @@ -83,8 +79,6 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => {
lastReviews: 4,
firstLabels: 10,
firstClosingIssues: 8,
includeIsAnswered: true,
includeStackEntry: true,
owner0: 'octocat',
name0: 'hello-world',
number0: 123,
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/utils/forges/github/graphql/common.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Custom directive that marks a field selection as gated behind a GitHub
# capability (e.g. `stackedPullRequests`, `answeredDiscussion`). The query
# sanitizer strips the directive (and the field itself for unsupported
# capabilities) before the query is sent, so it never reaches GitHub.
directive @gated(requires: String!) on FIELD

fragment AuthorFields on Actor {
login
htmlUrl: url
Expand Down
Loading