diff --git a/src/renderer/stores/useAccountsStore.ts b/src/renderer/stores/useAccountsStore.ts index 196423ac8..67fb556f4 100644 --- a/src/renderer/stores/useAccountsStore.ts +++ b/src/renderer/stores/useAccountsStore.ts @@ -7,7 +7,6 @@ import type { Account, Forge, Hostname, Token } from '../types'; import type { AuthMethod } from '../utils/auth/types'; import type { AccountsState, AccountsStore } from './types'; -import { resolvePlatform } from '../utils/auth/platform'; import { getAccountUUID, isValidHostname, refreshAccount } from '../utils/auth/utils'; import { rendererLogInfo, rendererLogWarn } from '../utils/core/logger'; import { getAdapter, isKnownForge } from '../utils/forges/registry'; @@ -64,7 +63,7 @@ const useAccountsStore = create()( forge, hostname: hostname, method: method, - platform: resolvePlatform(forge, hostname), + platform: getAdapter(forge).getPlatform(hostname), token: encryptedToken, username, user: null, // Will be updated during the refresh call below diff --git a/src/renderer/utils/auth/platform.test.ts b/src/renderer/utils/auth/platform.test.ts deleted file mode 100644 index 786929c6d..000000000 --- a/src/renderer/utils/auth/platform.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Hostname } from '../../types'; - -import { - getPlatformFromHostname, - isCloudDataResidencyHost, - isEnterpriseServerHost, - resolvePlatform, -} from './platform'; - -describe('renderer/utils/auth/platform.ts', () => { - describe('getPlatformFromHostname', () => { - it('should return GitHub Cloud', () => { - expect(getPlatformFromHostname('github.com' as Hostname)).toBe('GitHub Cloud'); - expect(getPlatformFromHostname('api.github.com' as Hostname)).toBe('GitHub Cloud'); - }); - - it('should return GitHub Enterprise Server', () => { - expect(getPlatformFromHostname('github.gitify.app' as Hostname)).toBe( - 'GitHub Enterprise Server', - ); - expect(getPlatformFromHostname('api.github.gitify.app' as Hostname)).toBe( - 'GitHub Enterprise Server', - ); - }); - - it('should return GitHub Enterprise Cloud with Data Residency for ghe.com domains', () => { - expect(getPlatformFromHostname('gitify.ghe.com' as Hostname)).toBe( - 'GitHub Enterprise Cloud with Data Residency', - ); - expect(getPlatformFromHostname('acme-corp.ghe.com' as Hostname)).toBe( - 'GitHub Enterprise Cloud with Data Residency', - ); - }); - }); - - describe('isCloudDataResidencyHost', () => { - it('should return true for ghe.com hosts', () => { - expect(isCloudDataResidencyHost('gitify.ghe.com' as Hostname)).toBe(true); - expect(isCloudDataResidencyHost('acme-corp.ghe.com' as Hostname)).toBe(true); - }); - - it('should return false for non ghe.com hosts', () => { - expect(isCloudDataResidencyHost('github.com' as Hostname)).toBe(false); - expect(isCloudDataResidencyHost('api.github.com' as Hostname)).toBe(false); - expect(isCloudDataResidencyHost('github.gitify.app' as Hostname)).toBe(false); - }); - }); - - describe('isEnterpriseServerHost', () => { - it('should return true for enterprise server host', () => { - expect(isEnterpriseServerHost('github.gitify.app' as Hostname)).toBe(true); - expect(isEnterpriseServerHost('api.github.gitify.app' as Hostname)).toBe(true); - }); - - it('should return false for github.com host', () => { - expect(isEnterpriseServerHost('github.com' as Hostname)).toBe(false); - expect(isEnterpriseServerHost('api.github.com' as Hostname)).toBe(false); - }); - - it('should return false for ghe.com host', () => { - expect(isEnterpriseServerHost('gitify.ghe.com' as Hostname)).toBe(false); - expect(isEnterpriseServerHost('acme-corp.ghe.com' as Hostname)).toBe(false); - }); - }); - - describe('resolvePlatform', () => { - it('returns Bitbucket Cloud for bitbucket forge', () => { - expect(resolvePlatform('bitbucket', 'bitbucket.org' as Hostname)).toBe('Bitbucket Cloud'); - }); - - it('returns Gitea for gitea forge', () => { - expect(resolvePlatform('gitea', 'gitea.example.com' as Hostname)).toBe('Gitea'); - }); - - it('returns GitHub Cloud for github.com', () => { - expect(resolvePlatform('github', 'github.com' as Hostname)).toBe('GitHub Cloud'); - }); - }); -}); diff --git a/src/renderer/utils/auth/platform.ts b/src/renderer/utils/auth/platform.ts deleted file mode 100644 index 878b92410..000000000 --- a/src/renderer/utils/auth/platform.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Constants } from '../../constants'; - -import type { Forge, Hostname } from '../../types'; -import type { PlatformType } from './types'; - -/** - * Resolve the UI platform label from forge + hostname. - * - * Gitea always reports as 'Gitea'; GitHub varies by hostname (Cloud, Enterprise - * Server, Enterprise Cloud with Data Residency). - */ -export function resolvePlatform(forge: Forge, hostname: Hostname): PlatformType { - if (forge === 'bitbucket') { - return 'Bitbucket Cloud'; - } - if (forge === 'gitea') { - return 'Gitea'; - } - return getPlatformFromHostname(hostname); -} - -export function getPlatformFromHostname(hostname: string): PlatformType { - if (hostname.endsWith(Constants.GITHUB_HOSTNAME)) { - return 'GitHub Cloud'; - } - - if (hostname.endsWith(Constants.GITHUB_ENTERPRISE_CLOUD_DATA_RESIDENCY_HOSTNAME)) { - return 'GitHub Enterprise Cloud with Data Residency'; - } - - return 'GitHub Enterprise Server'; -} - -export function isEnterpriseServerHost(hostname: Hostname): boolean { - return getPlatformFromHostname(hostname) === 'GitHub Enterprise Server'; -} - -export function isCloudDataResidencyHost(hostname: Hostname): boolean { - return getPlatformFromHostname(hostname) === 'GitHub Enterprise Cloud with Data Residency'; -} diff --git a/src/renderer/utils/forges/bitbucket/adapter.ts b/src/renderer/utils/forges/bitbucket/adapter.ts index ebb321552..1d08e8ed1 100644 --- a/src/renderer/utils/forges/bitbucket/adapter.ts +++ b/src/renderer/utils/forges/bitbucket/adapter.ts @@ -91,6 +91,7 @@ export const bitbucketAdapter: ForgeAdapter = { icon: BitbucketIcon, capabilities, + getPlatform: () => 'Bitbucket Cloud', formatUserLogin: (login) => login, fetchAuthenticatedUser, diff --git a/src/renderer/utils/forges/gitea/adapter.ts b/src/renderer/utils/forges/gitea/adapter.ts index 6c9e30f31..d50b7f45d 100644 --- a/src/renderer/utils/forges/gitea/adapter.ts +++ b/src/renderer/utils/forges/gitea/adapter.ts @@ -63,6 +63,7 @@ export const giteaAdapter: ForgeAdapter = { icon: ServerIcon, capabilities, + getPlatform: () => 'Gitea', formatUserLogin: (login) => `@${login}`, fetchAuthenticatedUser, diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts index 6117b4c21..2f3e38a58 100644 --- a/src/renderer/utils/forges/github/adapter.ts +++ b/src/renderer/utils/forges/github/adapter.ts @@ -31,6 +31,7 @@ import { } from './flows'; import { createNotificationHandler } from './handlers'; import { clearOctokitClientCacheForAccount, createOctokitClient } from './octokit'; +import { getGitHubPlatform } from './platform'; import { transformNotifications } from './transform'; async function fetchAuthenticatedUser(account: Account): Promise { @@ -80,6 +81,7 @@ export const githubAdapter: ForgeAdapter = { icon: MarkGithubIcon, capabilities: githubCapabilities, + getPlatform: getGitHubPlatform, formatUserLogin: (login) => `@${login}`, fetchAuthenticatedUser, diff --git a/src/renderer/utils/forges/github/auth.ts b/src/renderer/utils/forges/github/auth.ts index db1fe3cec..1f64405cc 100644 --- a/src/renderer/utils/forges/github/auth.ts +++ b/src/renderer/utils/forges/github/auth.ts @@ -7,8 +7,8 @@ import { Constants } from '../../../constants'; import type { Account, ClientID, Hostname, Link, Token } from '../../../types'; -import { getPlatformFromHostname } from '../../auth/platform'; import { getRecommendedScopeNames } from '../../auth/scopes'; +import { getGitHubPlatform } from './platform'; /** * Normalize a GitHub Enterprise Server version string to a semver string. @@ -39,7 +39,7 @@ export function extractHostVersion(version: string | null): string | undefined { * @returns The base URL to use for OAuth API requests. */ export function getGitHubAuthBaseUrl(hostname: Hostname): URL { - const platform = getPlatformFromHostname(hostname); + const platform = getGitHubPlatform(hostname); const url = new URL(APPLICATION.GITHUB_BASE_URL); switch (platform) { diff --git a/src/renderer/utils/forges/github/capabilities.test.ts b/src/renderer/utils/forges/github/capabilities.test.ts index 7dad988e4..b278953e9 100644 --- a/src/renderer/utils/forges/github/capabilities.test.ts +++ b/src/renderer/utils/forges/github/capabilities.test.ts @@ -3,7 +3,11 @@ import { mockGitHubEnterpriseServerAccount, } from '../../../__mocks__/account-mocks'; -import { githubCapabilities, supportsAnsweredDiscussion } from './capabilities'; +import { + githubCapabilities, + supportsAnsweredDiscussion, + supportsStackedPullRequests, +} from './capabilities'; describe('renderer/utils/forges/github/capabilities.ts', () => { describe('markAsDone', () => { @@ -71,4 +75,23 @@ describe('renderer/utils/forges/github/capabilities.ts', () => { ).toBe(false); }); }); + + describe('supportsStackedPullRequests', () => { + it('returns true for GitHub Cloud', () => { + expect(supportsStackedPullRequests(mockGitHubCloudAccount)).toBe(true); + }); + + it('returns false for GitHub Enterprise Server', () => { + expect(supportsStackedPullRequests(mockGitHubEnterpriseServerAccount)).toBe(false); + }); + + it('returns false when the GHES version is unknown', () => { + expect( + supportsStackedPullRequests({ + ...mockGitHubEnterpriseServerAccount, + version: undefined, + }), + ).toBe(false); + }); + }); }); diff --git a/src/renderer/utils/forges/github/capabilities.ts b/src/renderer/utils/forges/github/capabilities.ts index 12f96a953..e4472afa2 100644 --- a/src/renderer/utils/forges/github/capabilities.ts +++ b/src/renderer/utils/forges/github/capabilities.ts @@ -3,7 +3,7 @@ import semver from 'semver'; import type { Account } from '../../../types'; import type { ForgeCapabilities } from '../types'; -import { isEnterpriseServerHost } from '../../auth/platform'; +import { isGitHubCloudHost, isGitHubEnterpriseServerHost } from './platform'; /** * GitHub feature capabilities exposed through the forge adapter contract. @@ -14,7 +14,7 @@ import { isEnterpriseServerHost } from '../../auth/platform'; */ export const githubCapabilities: ForgeCapabilities = { markAsDone(account: Account): boolean { - if (!isEnterpriseServerHost(account.hostname)) { + if (!isGitHubEnterpriseServerHost(account.hostname)) { return true; } if (account.version) { @@ -36,7 +36,7 @@ export const githubCapabilities: ForgeCapabilities = { * GHES exposed `isAnswered` from version 3.12 onwards. */ export function supportsAnsweredDiscussion(account: Account): boolean { - if (!isEnterpriseServerHost(account.hostname)) { + if (!isGitHubEnterpriseServerHost(account.hostname)) { return true; } if (account.version) { @@ -44,3 +44,16 @@ export function supportsAnsweredDiscussion(account: Account): boolean { } return false; } + +/** + * GitHub-only capability: whether the GraphQL `PullRequest` schema exposes the + * native `stackEntry` field used for stacked PR metrics. Lives outside the + * shared `ForgeCapabilities` because no other forge supports stacked PRs and + * the only consumer is the GitHub GraphQL query construction in `client.ts`. + * + * Stacked PRs are a GitHub Cloud feature and are not available on GitHub + * Enterprise Server. + */ +export function supportsStackedPullRequests(account: Account): boolean { + return isGitHubCloudHost(account.hostname); +} diff --git a/src/renderer/utils/forges/github/client.test.ts b/src/renderer/utils/forges/github/client.test.ts index 4acdfe814..16fa1cfd5 100644 --- a/src/renderer/utils/forges/github/client.test.ts +++ b/src/renderer/utils/forges/github/client.test.ts @@ -392,6 +392,7 @@ describe('renderer/utils/forges/github/client.ts', () => { firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, lastReviews: Constants.GRAPHQL_ARGS.LAST_REVIEWS, + includeStackEntry: true, }, ); }); @@ -440,6 +441,7 @@ describe('renderer/utils/forges/github/client.ts', () => { firstClosingIssues: 100, firstLabels: 100, includeIsAnswered: true, + includeStackEntry: true, isDiscussionNotification0: false, isDiscussionNotification1: false, isIssueNotification0: true, diff --git a/src/renderer/utils/forges/github/client.ts b/src/renderer/utils/forges/github/client.ts index 7bcdd86a9..fee67607b 100644 --- a/src/renderer/utils/forges/github/client.ts +++ b/src/renderer/utils/forges/github/client.ts @@ -14,7 +14,7 @@ import type { } from './types'; import { reportServerPollInterval } from '../../notifications/pollInterval'; -import { supportsAnsweredDiscussion } from './capabilities'; +import { supportsAnsweredDiscussion, supportsStackedPullRequests } from './capabilities'; import { FetchDiscussionByNumberDocument, type FetchDiscussionByNumberQuery, @@ -250,6 +250,7 @@ export async function fetchPullByNumber( 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). @@ -297,6 +298,7 @@ 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, diff --git a/src/renderer/utils/forges/github/graphql/MergeQueryBuilder.test.ts b/src/renderer/utils/forges/github/graphql/MergeQueryBuilder.test.ts index cb23adc43..2a3cf94bf 100644 --- a/src/renderer/utils/forges/github/graphql/MergeQueryBuilder.test.ts +++ b/src/renderer/utils/forges/github/graphql/MergeQueryBuilder.test.ts @@ -13,6 +13,7 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => { firstLabels: 10, firstClosingIssues: 8, includeIsAnswered: true, + includeStackEntry: true, }; const nodeVarsA: FetchBatchMergedTemplateIndexedBaseVariables = { @@ -51,6 +52,7 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => { 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!'); @@ -82,6 +84,7 @@ describe('renderer/utils/forges/github/graphql/MergeQueryBuilder.ts', () => { firstLabels: 10, firstClosingIssues: 8, includeIsAnswered: true, + includeStackEntry: true, owner0: 'octocat', name0: 'hello-world', number0: 123, diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts index 2af087b47..032c60f65 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -248,6 +248,7 @@ export type FetchMergedDetailsTemplateQueryVariables = Exact<{ firstLabels?: number | null | undefined; firstClosingIssues?: number | null | undefined; includeIsAnswered: boolean; + includeStackEntry: boolean; }>; @@ -305,7 +306,7 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry?: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered?: boolean | null, url: Link, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -361,7 +362,7 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry?: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type FetchPullRequestByNumberQueryVariables = Exact<{ owner: string; @@ -371,6 +372,7 @@ export type FetchPullRequestByNumberQueryVariables = Exact<{ lastComments?: number | null | undefined; lastReviews?: number | null | undefined; firstClosingIssues?: number | null | undefined; + includeStackEntry: boolean; }>; @@ -398,7 +400,7 @@ export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typ | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry?: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -424,7 +426,7 @@ export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: nu | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry?: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type PullRequestReviewFieldsFragment = { state: PullRequestReviewState, author: | { login: string } @@ -741,7 +743,7 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` number } } - stackEntry { + stackEntry @include(if: $includeStackEntry) { position stack { size @@ -967,7 +969,7 @@ fragment PullRequestDetails on PullRequest { number } } - stackEntry { + stackEntry @include(if: $includeStackEntry) { position stack { size @@ -1133,7 +1135,7 @@ 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!) { + 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!, $includeStackEntry: Boolean!) { ...MergedDetailsQueryTemplate } fragment AuthorFields on Actor { @@ -1322,7 +1324,7 @@ fragment PullRequestDetails on PullRequest { number } } - stackEntry { + stackEntry @include(if: $includeStackEntry) { position stack { size @@ -1342,7 +1344,7 @@ fragment PullRequestReviewFields on PullRequestReview { } }`) 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) { + query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int, $includeStackEntry: Boolean!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ...PullRequestDetails @@ -1428,7 +1430,7 @@ fragment PullRequestDetails on PullRequest { number } } - stackEntry { + stackEntry @include(if: $includeStackEntry) { position stack { size diff --git a/src/renderer/utils/forges/github/graphql/merged.graphql b/src/renderer/utils/forges/github/graphql/merged.graphql index 141a5324b..8af450f16 100644 --- a/src/renderer/utils/forges/github/graphql/merged.graphql +++ b/src/renderer/utils/forges/github/graphql/merged.graphql @@ -14,6 +14,7 @@ query FetchMergedDetailsTemplate( $firstLabels: Int $firstClosingIssues: Int $includeIsAnswered: Boolean! + $includeStackEntry: Boolean! ) { ...MergedDetailsQueryTemplate } diff --git a/src/renderer/utils/forges/github/graphql/pull.graphql b/src/renderer/utils/forges/github/graphql/pull.graphql index efc5b543d..5711e5d6f 100644 --- a/src/renderer/utils/forges/github/graphql/pull.graphql +++ b/src/renderer/utils/forges/github/graphql/pull.graphql @@ -8,6 +8,7 @@ query FetchPullRequestByNumber( $lastComments: Int $lastReviews: Int $firstClosingIssues: Int + $includeStackEntry: Boolean! ) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -75,7 +76,7 @@ fragment PullRequestDetails on PullRequest { number } } - stackEntry { + stackEntry @include(if: $includeStackEntry) { position stack { size diff --git a/src/renderer/utils/forges/github/graphql/utils.test.ts b/src/renderer/utils/forges/github/graphql/utils.test.ts index 06d7501ca..1f4caffbb 100644 --- a/src/renderer/utils/forges/github/graphql/utils.test.ts +++ b/src/renderer/utils/forges/github/graphql/utils.test.ts @@ -87,7 +87,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { ); expect(varDefs).not.toBeNull(); - expect(varDefs.length).toBe(7); + expect(varDefs.length).toBe(8); expect(varDefs.flatMap((v) => v.name)).toEqual([ 'lastComments', 'lastThreadedComments', @@ -96,6 +96,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { 'firstLabels', 'firstClosingIssues', 'includeIsAnswered', + 'includeStackEntry', ]); }); }); diff --git a/src/renderer/utils/forges/github/platform.test.ts b/src/renderer/utils/forges/github/platform.test.ts new file mode 100644 index 000000000..d535a1d0e --- /dev/null +++ b/src/renderer/utils/forges/github/platform.test.ts @@ -0,0 +1,73 @@ +import type { Hostname } from '../../../types'; + +import { + getGitHubPlatform, + isGitHubCloudDataResidencyHost, + isGitHubCloudHost, + isGitHubEnterpriseServerHost, +} from './platform'; + +describe('renderer/utils/forges/github/platform.ts', () => { + describe('getGitHubPlatform', () => { + it('should return GitHub Cloud', () => { + expect(getGitHubPlatform('github.com')).toBe('GitHub Cloud'); + expect(getGitHubPlatform('api.github.com')).toBe('GitHub Cloud'); + }); + + it('should return GitHub Enterprise Server', () => { + expect(getGitHubPlatform('github.gitify.app')).toBe('GitHub Enterprise Server'); + expect(getGitHubPlatform('api.github.gitify.app')).toBe('GitHub Enterprise Server'); + }); + + it('should return GitHub Enterprise Cloud with Data Residency for ghe.com domains', () => { + expect(getGitHubPlatform('gitify.ghe.com')).toBe( + 'GitHub Enterprise Cloud with Data Residency', + ); + expect(getGitHubPlatform('acme-corp.ghe.com')).toBe( + 'GitHub Enterprise Cloud with Data Residency', + ); + }); + }); + + describe('isGitHubCloudHost', () => { + it('should return true for github.com hosts', () => { + expect(isGitHubCloudHost('github.com' as Hostname)).toBe(true); + expect(isGitHubCloudHost('api.github.com' as Hostname)).toBe(true); + }); + + it('should return false for non github.com hosts', () => { + expect(isGitHubCloudHost('github.gitify.app' as Hostname)).toBe(false); + expect(isGitHubCloudHost('gitify.ghe.com' as Hostname)).toBe(false); + }); + }); + + describe('isGitHubCloudDataResidencyHost', () => { + it('should return true for ghe.com hosts', () => { + expect(isGitHubCloudDataResidencyHost('gitify.ghe.com' as Hostname)).toBe(true); + expect(isGitHubCloudDataResidencyHost('acme-corp.ghe.com' as Hostname)).toBe(true); + }); + + it('should return false for non ghe.com hosts', () => { + expect(isGitHubCloudDataResidencyHost('github.com' as Hostname)).toBe(false); + expect(isGitHubCloudDataResidencyHost('api.github.com' as Hostname)).toBe(false); + expect(isGitHubCloudDataResidencyHost('github.gitify.app' as Hostname)).toBe(false); + }); + }); + + describe('isGitHubEnterpriseServerHost', () => { + it('should return true for enterprise server host', () => { + expect(isGitHubEnterpriseServerHost('github.gitify.app' as Hostname)).toBe(true); + expect(isGitHubEnterpriseServerHost('api.github.gitify.app' as Hostname)).toBe(true); + }); + + it('should return false for github.com host', () => { + expect(isGitHubEnterpriseServerHost('github.com' as Hostname)).toBe(false); + expect(isGitHubEnterpriseServerHost('api.github.com' as Hostname)).toBe(false); + }); + + it('should return false for ghe.com host', () => { + expect(isGitHubEnterpriseServerHost('gitify.ghe.com' as Hostname)).toBe(false); + expect(isGitHubEnterpriseServerHost('acme-corp.ghe.com' as Hostname)).toBe(false); + }); + }); +}); diff --git a/src/renderer/utils/forges/github/platform.ts b/src/renderer/utils/forges/github/platform.ts new file mode 100644 index 000000000..9197bbab5 --- /dev/null +++ b/src/renderer/utils/forges/github/platform.ts @@ -0,0 +1,34 @@ +import { Constants } from '../../../constants'; + +import type { Hostname } from '../../../types'; +import type { PlatformType } from '../../auth/types'; + +/** + * Resolve the GitHub platform label from the hostname. + * + * GitHub varies by hostname (Cloud, Enterprise Server, Enterprise Cloud with + * Data Residency). + */ +export function getGitHubPlatform(hostname: string): PlatformType { + if (hostname.endsWith(Constants.GITHUB_HOSTNAME)) { + return 'GitHub Cloud'; + } + + if (hostname.endsWith(Constants.GITHUB_ENTERPRISE_CLOUD_DATA_RESIDENCY_HOSTNAME)) { + return 'GitHub Enterprise Cloud with Data Residency'; + } + + return 'GitHub Enterprise Server'; +} + +export function isGitHubCloudHost(hostname: Hostname): boolean { + return getGitHubPlatform(hostname) === 'GitHub Cloud'; +} + +export function isGitHubEnterpriseServerHost(hostname: Hostname): boolean { + return getGitHubPlatform(hostname) === 'GitHub Enterprise Server'; +} + +export function isGitHubCloudDataResidencyHost(hostname: Hostname): boolean { + return getGitHubPlatform(hostname) === 'GitHub Enterprise Cloud with Data Residency'; +} diff --git a/src/renderer/utils/forges/github/utils.ts b/src/renderer/utils/forges/github/utils.ts index 3296277bd..13bd61bbe 100644 --- a/src/renderer/utils/forges/github/utils.ts +++ b/src/renderer/utils/forges/github/utils.ts @@ -3,10 +3,10 @@ import { Constants } from '../../../constants'; import type { Hostname } from '../../../types'; import type { APIClientType } from './types'; -import { getPlatformFromHostname } from '../../auth/platform'; +import { getGitHubPlatform } from './platform'; export function getGitHubAPIBaseUrl(hostname: Hostname, type: APIClientType) { - const platform = getPlatformFromHostname(hostname); + const platform = getGitHubPlatform(hostname); const url = new URL(Constants.GITHUB_API_BASE_URL); switch (platform) { diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts index 2a341667b..3a26a7628 100644 --- a/src/renderer/utils/forges/types.ts +++ b/src/renderer/utils/forges/types.ts @@ -18,6 +18,7 @@ import type { AuthResponse, DeviceFlowSession, LoginOAuthWebOptions, + PlatformType, } from '../auth/types'; /** @@ -104,6 +105,12 @@ export interface ForgeAdapter { /** Static or computed capability matrix for this forge. */ readonly capabilities: ForgeCapabilities; + /** + * Resolve the platform label (e.g. "GitHub Cloud") for a given hostname. + * Forges like GitHub vary by hostname; others report a single platform. + */ + getPlatform(hostname: Hostname): PlatformType; + /** * Format a user login for display (e.g. prepend "@" for GitHub/Gitea, * return as-is for Bitbucket where the login is an email address).