diff --git a/codegen.ts b/codegen.ts index 5843c768f..3d9a670fc 100644 --- a/codegen.ts +++ b/codegen.ts @@ -19,6 +19,9 @@ const config: CodegenConfig = { 'https://api.github.com/graphql': { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + // Issue fields are exposed behind this feature flag header; without it + // the schema omits `issueFieldValues` and its union types. + 'GraphQL-Features': 'issue_fields', }, // GitHub's live schema currently fails graphql-js's stricter // interface-deprecation-consistency validation (added in graphql v17). diff --git a/src/renderer/App.css b/src/renderer/App.css index c1eb988e7..d956c8acc 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -35,6 +35,9 @@ --gitify-icon-closed: var(--fgColor-closed); --gitify-icon-done: var(--fgColor-done); --gitify-icon-attention: var(--fgColor-attention); + --gitify-icon-severe: var(--fgColor-severe); + --gitify-icon-accent: var(--fgColor-accent); + --gitify-icon-sponsors: var(--fgColor-sponsors); --gitify-link: var(--fgColor-link); } @@ -82,6 +85,9 @@ --gitify-icon-closed: currentColor; --gitify-icon-done: currentColor; --gitify-icon-attention: currentColor; + --gitify-icon-severe: currentColor; + --gitify-icon-accent: currentColor; + --gitify-icon-sponsors: currentColor; --gitify-link: color-mix(in oklab, var(--fgColor-link), var(--fgColor-muted) 30%); } @@ -93,6 +99,9 @@ --gitify-icon-closed: color-mix(in oklab, var(--fgColor-closed), var(--fgColor-muted) 45%); --gitify-icon-done: color-mix(in oklab, var(--fgColor-done), var(--fgColor-muted) 45%); --gitify-icon-attention: color-mix(in oklab, var(--fgColor-attention), var(--fgColor-muted) 45%); + --gitify-icon-severe: color-mix(in oklab, var(--fgColor-severe), var(--fgColor-muted) 45%); + --gitify-icon-accent: color-mix(in oklab, var(--fgColor-accent), var(--fgColor-muted) 45%); + --gitify-icon-sponsors: color-mix(in oklab, var(--fgColor-sponsors), var(--fgColor-muted) 45%); } [data-theme='glass'] { @@ -474,6 +483,9 @@ --gitify-icon-closed: var(--fgColor-closed); --gitify-icon-done: var(--fgColor-done); --gitify-icon-attention: var(--fgColor-attention); + --gitify-icon-severe: var(--fgColor-severe); + --gitify-icon-accent: var(--fgColor-accent); + --gitify-icon-sponsors: var(--fgColor-sponsors); --gitify-link: var(--fgColor-link); } diff --git a/src/renderer/components/metrics/LabelsPill.test.tsx b/src/renderer/components/metrics/LabelsPill.test.tsx index f5ca1f674..b137628fa 100644 --- a/src/renderer/components/metrics/LabelsPill.test.tsx +++ b/src/renderer/components/metrics/LabelsPill.test.tsx @@ -1,9 +1,11 @@ import { renderWithProviders } from '../../__helpers__/test-utils'; +import { IconColor } from '../../types'; + import { LabelsPill, type LabelsPillProps } from './LabelsPill'; describe('renderer/components/metrics/LabelsPill.tsx', () => { - it('renders without labels', () => { + it('renders without labels or issue fields', () => { const props: LabelsPillProps = { labels: [] }; const tree = renderWithProviders(); @@ -23,4 +25,36 @@ describe('renderer/components/metrics/LabelsPill.tsx', () => { expect(tree.container).toMatchSnapshot(); }); + + it('renders field tokens when there are no labels', () => { + const props: LabelsPillProps = { + labels: [], + issueFields: [{ name: 'Priority', value: 'High', color: IconColor.RED }], + }; + + const tree = renderWithProviders(); + + expect(tree.getByText('Priority: High')).toBeInTheDocument(); + expect(tree.container.innerHTML).toContain(IconColor.RED); + expect(tree.container.innerHTML).toContain('var(--gitify-icon-closed)'); + }); + + it('renders field tokens prepended before labels', () => { + const props: LabelsPillProps = { + labels: [{ name: 'enhancement', color: 'a2eeef' }], + issueFields: [ + { name: 'Priority', value: 'High', color: IconColor.RED }, + { name: 'Effort', value: '5' }, + ], + }; + + const tree = renderWithProviders(); + const textContent = tree.container.textContent!; + + expect(textContent).toContain('Priority: High'); + expect(textContent).toContain('Effort: 5'); + expect(textContent).toContain('enhancement'); + expect(textContent.indexOf('Priority: High')).toBeLessThan(textContent.indexOf('enhancement')); + expect(textContent.indexOf('Effort: 5')).toBeLessThan(textContent.indexOf('enhancement')); + }); }); diff --git a/src/renderer/components/metrics/LabelsPill.tsx b/src/renderer/components/metrics/LabelsPill.tsx index 5276c5a1a..2ed3c2a1b 100644 --- a/src/renderer/components/metrics/LabelsPill.tsx +++ b/src/renderer/components/metrics/LabelsPill.tsx @@ -1,42 +1,73 @@ -import type { FC } from 'react'; +import type { CSSProperties, FC } from 'react'; import { TagIcon } from '@primer/octicons-react'; import { IssueLabelToken, LabelGroup } from '@primer/react'; -import { type GitifyLabels, IconColor } from '../../types'; +import { type GitifyIssueField, type GitifyLabels, IconColor } from '../../types'; import { MetricPill } from './MetricPill'; export interface LabelsPillProps { labels: GitifyLabels[]; + issueFields?: GitifyIssueField[]; } -export const LabelsPill: FC = ({ labels }) => { - if (!labels?.length) { +/** + * Resolve an {@link IconColor} token (a `text-gitify-icon-*` Tailwind class) to + * the Primer-backed CSS variable it maps to. Primer's label token styles set + * their own computed `color`, which would beat the utility class in the + * cascade, so the field colour is applied inline instead. + */ +export const iconColorCssVar = (color: IconColor): string => + `var(--${color.replace('text-gitify-icon-', 'gitify-icon-')})`; + +export const LabelsPill: FC = ({ labels, issueFields }) => { + const fieldTokens = (issueFields ?? []).map((field) => ({ + text: `${field.name}: ${field.value}`, + color: field.color, + })); + + const labelsContent = + labels?.length || fieldTokens.length ? ( + + {fieldTokens.map((field) => { + const style: CSSProperties | undefined = field.color + ? { color: iconColorCssVar(field.color) } + : undefined; + + return ( + + ); + })} + {(labels ?? []).map((label) => { + return ( + + ); + })} + + ) : null; + + if (!labelsContent) { return null; } - const labelsContent = ( - - {labels.map((label) => { - return ( - - ); - })} - - ); - return ( ); }; diff --git a/src/renderer/components/metrics/MetricGroup.test.tsx b/src/renderer/components/metrics/MetricGroup.test.tsx index e28e0585f..a2169a455 100644 --- a/src/renderer/components/metrics/MetricGroup.test.tsx +++ b/src/renderer/components/metrics/MetricGroup.test.tsx @@ -70,4 +70,44 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => { expect(tree.getByText('2/3')).toBeInTheDocument(); }); + + it('should render issue field pills immediately before label pills', async () => { + const props: MetricGroupProps = { + notification: { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + issueFields: [{ name: 'Priority', value: 'High', color: IconColor.RED }], + labels: [{ name: 'enhancement', color: '0e8a16' }], + }, + }, + }; + + const tree = renderWithProviders(, { + settings: { ...mockSettings, showPills: true }, + }); + + const textContent = tree.container.textContent; + expect(textContent).toContain('Priority: High'); + expect(textContent).toContain('enhancement'); + expect(textContent.indexOf('Priority: High')).toBeLessThan(textContent.indexOf('enhancement')); + }); + + it('should not render field pills when showPills is disabled', async () => { + const props: MetricGroupProps = { + notification: { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + issueFields: [{ name: 'Priority', value: 'High', color: IconColor.RED }], + }, + }, + }; + + const tree = renderWithProviders(, { + settings: { ...mockSettings, showPills: false }, + }); + + expect(tree.queryByText('Priority: High')).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/metrics/MetricGroup.tsx b/src/renderer/components/metrics/MetricGroup.tsx index c91976292..411dd6b61 100644 --- a/src/renderer/components/metrics/MetricGroup.tsx +++ b/src/renderer/components/metrics/MetricGroup.tsx @@ -47,7 +47,10 @@ export const MetricGroup: FC = ({ notification }) => { - + ); }; diff --git a/src/renderer/components/metrics/__snapshots__/LabelsPill.test.tsx.snap b/src/renderer/components/metrics/__snapshots__/LabelsPill.test.tsx.snap index cb69b70b9..b885d1461 100644 --- a/src/renderer/components/metrics/__snapshots__/LabelsPill.test.tsx.snap +++ b/src/renderer/components/metrics/__snapshots__/LabelsPill.test.tsx.snap @@ -101,4 +101,4 @@ exports[`renderer/components/metrics/LabelsPill.tsx > renders with labels 1`] = `; -exports[`renderer/components/metrics/LabelsPill.tsx > renders without labels 1`] = `
`; +exports[`renderer/components/metrics/LabelsPill.tsx > renders without labels or issue fields 1`] = `
`; diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 340011219..f1b846a23 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -97,6 +97,7 @@ export const Constants = { GRAPHQL_ARGS: { FIRST_LABELS: 100, FIRST_CLOSING_ISSUES: 100, + FIRST_ISSUE_FIELD_VALUES: 100, LAST_COMMENTS: 1, LAST_THREADED_COMMENTS: 10, LAST_REPLIES: 10, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 598d1e907..291c9e33d 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -263,6 +263,9 @@ export enum IconColor { PURPLE = 'text-gitify-icon-done', RED = 'text-gitify-icon-closed', YELLOW = 'text-gitify-icon-attention', + ORANGE = 'text-gitify-icon-severe', + BLUE = 'text-gitify-icon-accent', + PINK = 'text-gitify-icon-sponsors', } export enum Opacity { @@ -406,6 +409,8 @@ export interface GitifySubject { stackDepth?: number; /** GitHub-native issue type (e.g. Bug, Feature, Task) */ issueType?: GitifyIssueType; + /** GitHub issue fields (e.g. Priority, Effort) with values set */ + issueFields?: GitifyIssueField[]; /** Milestone state/title */ milestone?: GitifyMilestone; /** Deep link to notification thread */ @@ -483,6 +488,16 @@ export interface GitifyIssueType { color: IconColor; } +/** GitHub issue field value, normalized for display */ +export interface GitifyIssueField { + /** Field name, e.g. "Priority" */ + name: string; + /** Display value, e.g. "High", "5", "2026-09-01" */ + value: string; + /** Option color token when available */ + color?: IconColor; +} + export type GitifyMilestone = MilestoneFieldsFragment; export type GitifyReactionGroup = ReactionGroupFieldsFragment; diff --git a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts index e9148c87f..2b1f7effe 100644 --- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts +++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts @@ -102,6 +102,7 @@ export function mockIssueResponseNode(mocks: { comments: { totalCount: 0, nodes: [] }, milestone: null, issueType: null, + issueFieldValues: null, reactions: { totalCount: 0, }, diff --git a/src/renderer/utils/forges/github/capabilities.test.ts b/src/renderer/utils/forges/github/capabilities.test.ts index cedee0941..c9cb54807 100644 --- a/src/renderer/utils/forges/github/capabilities.test.ts +++ b/src/renderer/utils/forges/github/capabilities.test.ts @@ -7,6 +7,7 @@ import { githubCapabilities, getGitHubCapabilities, supportsAnsweredDiscussion, + supportsIssueFields, supportsStackedPullRequests, } from './capabilities'; @@ -96,11 +97,45 @@ describe('renderer/utils/forges/github/capabilities.ts', () => { }); }); + describe('supportsIssueFields', () => { + it('returns true for GitHub Cloud', () => { + expect(supportsIssueFields(mockGitHubCloudAccount)).toBe(true); + }); + + it('returns false for GitHub Enterprise Server < v3.23', () => { + expect( + supportsIssueFields({ + ...mockGitHubEnterpriseServerAccount, + version: '3.22.0', + }), + ).toBe(false); + }); + + it('returns true for GitHub Enterprise Server >= v3.23', () => { + expect( + supportsIssueFields({ + ...mockGitHubEnterpriseServerAccount, + version: '3.23.0', + }), + ).toBe(true); + }); + + it('returns false when the GHES version is unknown', () => { + expect( + supportsIssueFields({ + ...mockGitHubEnterpriseServerAccount, + version: undefined, + }), + ).toBe(false); + }); + }); + describe('getGitHubCapabilities', () => { it('enables all gated capabilities for GitHub Cloud', () => { expect(getGitHubCapabilities(mockGitHubCloudAccount)).toEqual({ stackedPullRequests: true, answeredDiscussion: true, + issueFields: true, }); }); @@ -108,6 +143,20 @@ describe('renderer/utils/forges/github/capabilities.ts', () => { expect(getGitHubCapabilities(mockGitHubEnterpriseServerAccount)).toEqual({ stackedPullRequests: false, answeredDiscussion: false, + issueFields: false, + }); + }); + + it('enables issueFields for GitHub Enterprise Server >= v3.23', () => { + expect( + getGitHubCapabilities({ + ...mockGitHubEnterpriseServerAccount, + version: '3.23.0', + }), + ).toEqual({ + stackedPullRequests: false, + answeredDiscussion: true, + issueFields: true, }); }); }); diff --git a/src/renderer/utils/forges/github/capabilities.ts b/src/renderer/utils/forges/github/capabilities.ts index 1edeb31ab..b1a48143b 100644 --- a/src/renderer/utils/forges/github/capabilities.ts +++ b/src/renderer/utils/forges/github/capabilities.ts @@ -58,6 +58,26 @@ export function supportsStackedPullRequests(account: Account): boolean { return isGitHubCloudHost(account.hostname); } +/** + * GitHub-only capability: whether the GraphQL `Issue` schema exposes the + * native `issueFieldValues` field used for issue field metrics. Lives outside + * the shared `ForgeCapabilities` because no other forge supports issue fields + * and the only consumer is the GitHub GraphQL query construction in + * `client.ts`. + * + * Issue fields are a GitHub Cloud feature and ship in GitHub Enterprise + * Server from version 3.23 onwards. + */ +export function supportsIssueFields(account: Account): boolean { + if (!isGitHubEnterpriseServerHost(account.hostname)) { + return true; + } + if (account.version) { + return semver.gte(account.version, '3.23.0'); + } + return false; +} + /** * The set of capabilities that gate GraphQL field selections via the custom * `@gated(requires: ...)` directive. The keys must match the `requires` @@ -66,6 +86,7 @@ export function supportsStackedPullRequests(account: Account): boolean { export type GitHubGatedCapabilities = { stackedPullRequests: boolean; answeredDiscussion: boolean; + issueFields: boolean; }; /** @@ -77,5 +98,6 @@ export function getGitHubCapabilities(account: Account): GitHubGatedCapabilities return { stackedPullRequests: supportsStackedPullRequests(account), answeredDiscussion: supportsAnsweredDiscussion(account), + issueFields: supportsIssueFields(account), }; } diff --git a/src/renderer/utils/forges/github/client.test.ts b/src/renderer/utils/forges/github/client.test.ts index 8893f2ce7..bd3ed8699 100644 --- a/src/renderer/utils/forges/github/client.test.ts +++ b/src/renderer/utils/forges/github/client.test.ts @@ -386,6 +386,7 @@ describe('renderer/utils/forges/github/client.ts', () => { name: mockNotification.repository.name, number: 123, firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + firstIssueFieldValues: Constants.GRAPHQL_ARGS.FIRST_ISSUE_FIELD_VALUES, lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, }, ); @@ -488,6 +489,7 @@ describe('renderer/utils/forges/github/client.ts', () => { expect.stringMatching(/node0|node1/), { firstClosingIssues: 100, + firstIssueFieldValues: 100, firstLabels: 100, firstReviewThreads: 100, isDiscussionNotification0: false, @@ -512,6 +514,7 @@ describe('renderer/utils/forges/github/client.ts', () => { const query = performGraphQLRequestStringSpy.mock.calls[0][1]; expect(query).toContain('stackEntry'); expect(query).toContain('isAnswered'); + expect(query).toContain('issueFieldValues'); expect(query).not.toContain('@gated'); }); @@ -534,6 +537,7 @@ describe('renderer/utils/forges/github/client.ts', () => { expect(account).toBe(mockGitHubEnterpriseServerAccount); expect(query).not.toContain('stackEntry'); expect(query).not.toContain('isAnswered'); + expect(query).not.toContain('issueFieldValues'); expect(query).not.toContain('@gated'); expect(query).toContain('FetchMergedNotifications'); expect(variables).not.toHaveProperty('includeStackEntry'); diff --git a/src/renderer/utils/forges/github/client.ts b/src/renderer/utils/forges/github/client.ts index 11da54983..145c37f8a 100644 --- a/src/renderer/utils/forges/github/client.ts +++ b/src/renderer/utils/forges/github/client.ts @@ -235,6 +235,7 @@ export async function fetchIssueByNumber( name: notification.repository.name, number: number, firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + firstIssueFieldValues: Constants.GRAPHQL_ARGS.FIRST_ISSUE_FIELD_VALUES, lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, }); } @@ -311,6 +312,7 @@ export async function fetchNotificationDetailsForList( builder.setSharedVariables({ firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES, firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, + firstIssueFieldValues: Constants.GRAPHQL_ARGS.FIRST_ISSUE_FIELD_VALUES, lastComments: Constants.GRAPHQL_ARGS.LAST_COMMENTS, lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS, lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES, diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts index 47f81fa43..f0730df97 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -15,6 +15,25 @@ export type DiscussionStateReason = /** The discussion has been resolved */ | 'RESOLVED'; +/** The display color of a single-select field option. */ +export type IssueFieldSingleSelectOptionColor = + /** blue */ + | 'BLUE' + /** gray */ + | 'GRAY' + /** green */ + | 'GREEN' + /** orange */ + | 'ORANGE' + /** pink */ + | 'PINK' + /** purple */ + | 'PURPLE' + /** red */ + | 'RED' + /** yellow */ + | 'YELLOW'; + /** The possible states of an issue. */ export type IssueState = /** An issue that has been closed */ @@ -202,6 +221,7 @@ export type FetchIssueByNumberQueryVariables = Exact<{ number: number; lastComments?: number | null | undefined; firstLabels?: number | null | undefined; + firstIssueFieldValues?: number | null | undefined; }>; @@ -217,7 +237,28 @@ export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issu | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, issueFieldValues: { nodes: Array< + | { __typename: 'IssueFieldDateValue', dateValue: string, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldMultiSelectValue', options: Array<{ name: string, color: IssueFieldSingleSelectOptionColor }>, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldNumberValue', numberValue: number, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldSingleSelectValue', name: string, color: IssueFieldSingleSelectOptionColor, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldTextValue', textValue: string, field: + | { name: string } + | Record + | null } + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: string, url: Link, state: IssueState, stateReason: IssueStateReason | null, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -231,7 +272,28 @@ export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, issueFieldValues: { nodes: Array< + | { __typename: 'IssueFieldDateValue', dateValue: string, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldMultiSelectValue', options: Array<{ name: string, color: IssueFieldSingleSelectOptionColor }>, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldNumberValue', numberValue: number, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldSingleSelectValue', name: string, color: IssueFieldSingleSelectOptionColor, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldTextValue', textValue: string, field: + | { name: string } + | Record + | null } + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type FetchMergedDetailsTemplateQueryVariables = Exact<{ ownerINDEX: string; @@ -247,6 +309,7 @@ export type FetchMergedDetailsTemplateQueryVariables = Exact<{ firstReviewThreads?: number | null | undefined; firstLabels?: number | null | undefined; firstClosingIssues?: number | null | undefined; + firstIssueFieldValues?: number | null | undefined; }>; @@ -280,7 +343,28 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, issueFieldValues: { nodes: Array< + | { __typename: 'IssueFieldDateValue', dateValue: string, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldMultiSelectValue', options: Array<{ name: string, color: IssueFieldSingleSelectOptionColor }>, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldNumberValue', numberValue: number, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldSingleSelectValue', name: string, color: IssueFieldSingleSelectOptionColor, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldTextValue', textValue: string, field: + | { name: string } + | Record + | null } + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __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' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -342,7 +426,28 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, issueType: { name: string, color: IssueTypeColor } | null, issueFieldValues: { nodes: Array< + | { __typename: 'IssueFieldDateValue', dateValue: string, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldMultiSelectValue', options: Array<{ name: string, color: IssueFieldSingleSelectOptionColor }>, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldNumberValue', numberValue: number, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldSingleSelectValue', name: string, color: IssueFieldSingleSelectOptionColor, field: + | { name: string } + | Record + | null } + | { __typename: 'IssueFieldTextValue', textValue: string, field: + | { name: string } + | Record + | null } + | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null, pullRequest?: { __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' } | { name: string | null, login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -702,6 +807,55 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` name color } + issueFieldValues(first: $firstIssueFieldValues) @gated(requires: "issueFields") { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + color + field { + ... on IssueFieldSingleSelect { + name + } + } + } + ... on IssueFieldMultiSelectValue { + options { + name + color + } + field { + ... on IssueFieldMultiSelect { + name + } + } + } + ... on IssueFieldTextValue { + textValue: value + field { + ... on IssueFieldText { + name + } + } + } + ... on IssueFieldNumberValue { + numberValue: value + field { + ... on IssueFieldNumber { + name + } + } + } + ... on IssueFieldDateValue { + dateValue: value + field { + ... on IssueFieldDate { + name + } + } + } + } + } reactions { totalCount } @@ -1020,6 +1174,55 @@ fragment IssueDetails on Issue { name color } + issueFieldValues(first: $firstIssueFieldValues) @gated(requires: "issueFields") { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + color + field { + ... on IssueFieldSingleSelect { + name + } + } + } + ... on IssueFieldMultiSelectValue { + options { + name + color + } + field { + ... on IssueFieldMultiSelect { + name + } + } + } + ... on IssueFieldTextValue { + textValue: value + field { + ... on IssueFieldText { + name + } + } + } + ... on IssueFieldNumberValue { + numberValue: value + field { + ... on IssueFieldNumber { + name + } + } + } + ... on IssueFieldDateValue { + dateValue: value + field { + ... on IssueFieldDate { + name + } + } + } + } + } reactions { totalCount } @@ -1204,7 +1407,7 @@ fragment DiscussionCommentFields on DiscussionComment { } }`) as unknown as TypedDocumentString; export const FetchIssueByNumberDocument = new TypedDocumentString(` - query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) { + query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int, $firstIssueFieldValues: Int) { repository(owner: $owner, name: $name) { issue(number: $number) { ...IssueDetails @@ -1274,6 +1477,55 @@ fragment IssueDetails on Issue { name color } + issueFieldValues(first: $firstIssueFieldValues) @gated(requires: "issueFields") { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + color + field { + ... on IssueFieldSingleSelect { + name + } + } + } + ... on IssueFieldMultiSelectValue { + options { + name + color + } + field { + ... on IssueFieldMultiSelect { + name + } + } + } + ... on IssueFieldTextValue { + textValue: value + field { + ... on IssueFieldText { + name + } + } + } + ... on IssueFieldNumberValue { + numberValue: value + field { + ... on IssueFieldNumber { + name + } + } + } + ... on IssueFieldDateValue { + dateValue: value + field { + ... on IssueFieldDate { + name + } + } + } + } + } reactions { totalCount } @@ -1282,7 +1534,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, $firstReviewThreads: Int, $firstLabels: Int, $firstClosingIssues: Int) { + query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstReviewThreads: Int, $firstLabels: Int, $firstClosingIssues: Int, $firstIssueFieldValues: Int) { ...MergedDetailsQueryTemplate } fragment AuthorFields on Actor { @@ -1398,6 +1650,55 @@ fragment IssueDetails on Issue { name color } + issueFieldValues(first: $firstIssueFieldValues) @gated(requires: "issueFields") { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + color + field { + ... on IssueFieldSingleSelect { + name + } + } + } + ... on IssueFieldMultiSelectValue { + options { + name + color + } + field { + ... on IssueFieldMultiSelect { + name + } + } + } + ... on IssueFieldTextValue { + textValue: value + field { + ... on IssueFieldText { + name + } + } + } + ... on IssueFieldNumberValue { + numberValue: value + field { + ... on IssueFieldNumber { + name + } + } + } + ... on IssueFieldDateValue { + dateValue: value + field { + ... on IssueFieldDate { + name + } + } + } + } + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/issue.graphql b/src/renderer/utils/forges/github/graphql/issue.graphql index 745370cf9..8c6206e5f 100644 --- a/src/renderer/utils/forges/github/graphql/issue.graphql +++ b/src/renderer/utils/forges/github/graphql/issue.graphql @@ -6,6 +6,7 @@ query FetchIssueByNumber( $number: Int! $lastComments: Int $firstLabels: Int + $firstIssueFieldValues: Int ) { repository(owner: $owner, name: $name) { issue(number: $number) { @@ -51,6 +52,55 @@ fragment IssueDetails on Issue { name color } + issueFieldValues(first: $firstIssueFieldValues) @gated(requires: "issueFields") { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + color + field { + ... on IssueFieldSingleSelect { + name + } + } + } + ... on IssueFieldMultiSelectValue { + options { + name + color + } + field { + ... on IssueFieldMultiSelect { + name + } + } + } + ... on IssueFieldTextValue { + textValue: value + field { + ... on IssueFieldText { + name + } + } + } + ... on IssueFieldNumberValue { + numberValue: value + field { + ... on IssueFieldNumber { + name + } + } + } + ... on IssueFieldDateValue { + dateValue: value + field { + ... on IssueFieldDate { + name + } + } + } + } + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/merged.graphql b/src/renderer/utils/forges/github/graphql/merged.graphql index 71c903de1..b3f7af930 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( $firstReviewThreads: Int $firstLabels: Int $firstClosingIssues: Int + $firstIssueFieldValues: Int ) { ...MergedDetailsQueryTemplate } diff --git a/src/renderer/utils/forges/github/graphql/utils.test.ts b/src/renderer/utils/forges/github/graphql/utils.test.ts index adf64a2f5..bb3c9618c 100644 --- a/src/renderer/utils/forges/github/graphql/utils.test.ts +++ b/src/renderer/utils/forges/github/graphql/utils.test.ts @@ -90,7 +90,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', @@ -99,6 +99,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { 'firstReviewThreads', 'firstLabels', 'firstClosingIssues', + 'firstIssueFieldValues', ]); }); }); @@ -126,6 +127,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { const allCapabilities = { stackedPullRequests: true, answeredDiscussion: true, + issueFields: true, }; it('strips @gated directives but keeps gated fields when supported', () => { @@ -161,6 +163,7 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { expect(result).not.toContain('@gated'); expect(result).toContain('stackEntry'); expect(result).toContain('isAnswered'); + expect(result).toContain('issueFieldValues'); expect(result).toContain('query FetchMergedDetailsTemplate'); }); @@ -168,10 +171,12 @@ describe('renderer/utils/forges/github/graphql/utils.ts', () => { const result = stripGatedSelections(FetchMergedDetailsTemplateDocument.toString(), { stackedPullRequests: false, answeredDiscussion: false, + issueFields: false, }); expect(result).not.toContain('stackEntry'); expect(result).not.toContain('isAnswered'); + expect(result).not.toContain('issueFieldValues'); expect(result).not.toContain('@gated'); expect(result).toContain('query FetchMergedDetailsTemplate'); expect(result).toContain('PullRequestDetails'); diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts index f106b12fa..f4e0fdab8 100644 --- a/src/renderer/utils/forges/github/handlers/issue.test.ts +++ b/src/renderer/utils/forges/github/handlers/issue.test.ts @@ -70,6 +70,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { commentCount: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, labels: [], + issueFields: [], milestone: undefined, reactionsCount: 0, reactionGroups: noReactionGroups, @@ -108,6 +109,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { commentCount: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, labels: [], + issueFields: [], milestone: undefined, reactionsCount: 0, reactionGroups: noReactionGroups, @@ -165,6 +167,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123#issuecomment-1234' as Link, labels: [], + issueFields: [], milestone: undefined, reactionsCount: 0, reactionGroups: noReactionGroups, @@ -205,6 +208,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { commentCount: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, labels: [{ name: 'enhancement', color: '0e8a16' }], + issueFields: [], milestone: undefined, reactionsCount: 0, reactionGroups: noReactionGroups, @@ -246,6 +250,7 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { commentCount: 0, htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, labels: [], + issueFields: [], milestone: { state: 'OPEN', title: 'Open Milestone', @@ -291,11 +296,94 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, labels: [], issueType: { name: 'Bug', color: IconColor.RED }, + issueFields: [], milestone: undefined, reactionsCount: 0, reactionGroups: noReactionGroups, } satisfies Partial); }); + + it('with issue fields', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + mockIssue.issueFieldValues = { + nodes: [ + { + __typename: 'IssueFieldSingleSelectValue', + name: 'High', + color: 'RED', + field: { name: 'Priority' }, + }, + { + __typename: 'IssueFieldMultiSelectValue', + options: [ + { name: 'Mobile', color: 'BLUE' }, + { name: 'Web', color: 'GREEN' }, + ], + field: { name: 'Platform' }, + }, + { + __typename: 'IssueFieldTextValue', + textValue: 'Customer-facing', + field: { name: 'Impact' }, + }, + { + __typename: 'IssueFieldNumberValue', + numberValue: 5, + field: { name: 'Effort' }, + }, + { + __typename: 'IssueFieldDateValue', + dateValue: '2026-09-01', + field: { name: 'Target date' }, + }, + { + __typename: 'IssueFieldSingleSelectValue', + name: '', + color: 'GRAY', + field: { name: 'Empty field' }, + }, + null, + ], + }; + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result.issueFields).toEqual([ + { name: 'Priority', value: 'High', color: IconColor.RED }, + { + name: 'Platform', + value: 'Mobile, Web', + color: IconColor.BLUE, + }, + { name: 'Impact', value: 'Customer-facing' }, + { name: 'Effort', value: '5' }, + { name: 'Target date', value: '2026-09-01' }, + ]); + }); + + it('omits fields when issueFieldValues is absent or empty', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result.issueFields).toEqual([]); + }); }); describe('iconType', () => { diff --git a/src/renderer/utils/forges/github/handlers/issue.ts b/src/renderer/utils/forges/github/handlers/issue.ts index 657d4b5ef..66f28ba4d 100644 --- a/src/renderer/utils/forges/github/handlers/issue.ts +++ b/src/renderer/utils/forges/github/handlers/issue.ts @@ -8,13 +8,88 @@ import { SkipIcon, } from '@primer/octicons-react'; -import type { GitifyIssueState, GitifyNotification, GitifySubject, Link } from '../../../../types'; +import type { + GitifyIssueField, + GitifyIssueState, + GitifyNotification, + GitifySubject, + Link, +} from '../../../../types'; import { IconColor } from '../../../../types'; import { fetchIssueByNumber } from '../client'; import type { IssueDetailsFragment } from '../graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; -import { getNotificationAuthor, mapIssueTypeColor } from './utils'; +import { getNotificationAuthor, mapIssueFieldColor, mapIssueTypeColor } from './utils'; + +/** + * A single node in the `issueFieldValues` GraphQL connection. + */ +type IssueFieldValueNode = NonNullable< + NonNullable['nodes'] +>[number]; + +/** + * Map a GitHub issue field value node to a normalized {@link GitifyIssueField}. + * + * Only nodes that carry a value and a resolvable field name are returned; + * otherwise `undefined` so callers can filter out unset fields. + */ +function mapIssueFieldValue(node: IssueFieldValueNode): GitifyIssueField | undefined { + if (!node) { + return undefined; + } + + switch (node.__typename) { + case 'IssueFieldSingleSelectValue': { + const fieldName = node.field && 'name' in node.field ? node.field.name : undefined; + if (!fieldName || !node.name) { + return undefined; + } + return { + name: fieldName, + value: node.name, + color: mapIssueFieldColor(node.color), + }; + } + case 'IssueFieldMultiSelectValue': { + const fieldName = node.field && 'name' in node.field ? node.field.name : undefined; + const optionNames = node.options.map((option) => option.name); + if (!fieldName || optionNames.length === 0) { + return undefined; + } + const coloredOption = node.options.find((option) => option.color); + return { + name: fieldName, + value: optionNames.join(', '), + ...(coloredOption ? { color: mapIssueFieldColor(coloredOption.color) } : {}), + }; + } + case 'IssueFieldTextValue': { + const fieldName = node.field && 'name' in node.field ? node.field.name : undefined; + if (!fieldName || !node.textValue) { + return undefined; + } + return { name: fieldName, value: node.textValue }; + } + case 'IssueFieldDateValue': { + const fieldName = node.field && 'name' in node.field ? node.field.name : undefined; + if (!fieldName || !node.dateValue) { + return undefined; + } + return { name: fieldName, value: node.dateValue }; + } + case 'IssueFieldNumberValue': { + const fieldName = node.field && 'name' in node.field ? node.field.name : undefined; + if (!fieldName || node.numberValue === null || node.numberValue === undefined) { + return undefined; + } + return { name: fieldName, value: String(node.numberValue) }; + } + default: + return undefined; + } +} class IssueHandler extends DefaultHandler { override readonly supportsMergedQueryEnrichment = true; @@ -55,6 +130,10 @@ class IssueHandler extends DefaultHandler { issueType: issue.issueType ? { name: issue.issueType.name, color: mapIssueTypeColor(issue.issueType.color) } : undefined, + issueFields: + (issue.issueFieldValues?.nodes ?? []) + .map((node) => mapIssueFieldValue(node)) + .filter((field): field is GitifyIssueField => field !== undefined) ?? [], milestone: issue.milestone ?? undefined, htmlUrl: issueComment?.url ?? issue.url, reactionsCount: issueReactionCount, diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts index 7aa19604a..5d708180d 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts @@ -776,6 +776,30 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { ]); }); + it('selects the latest reviewer state regardless of connection order', () => { + const result = getPullRequestReviewers(mockGitHubCloudAccount, [ + { + author: mockAuthorResponseNode('reviewer-1'), + state: 'APPROVED' as PullRequestReviewState, + submittedAt: '2026-01-01T12:00:00Z', + }, + { + author: mockAuthorResponseNode('reviewer-1'), + state: 'COMMENTED' as PullRequestReviewState, + submittedAt: '2026-01-01T13:00:00Z', + }, + { + author: mockAuthorResponseNode('reviewer-1'), + state: 'CHANGES_REQUESTED' as PullRequestReviewState, + submittedAt: '2026-01-01T11:00:00Z', + }, + ]); + + expect(result).toEqual([ + { user: 'reviewer-1', state: 'COMMENTED', threads: { resolved: 0, total: 0 } }, + ]); + }); + it('handles no reviews or threads', () => { const result = getPullRequestReviewers(mockGitHubCloudAccount, []); diff --git a/src/renderer/utils/forges/github/handlers/utils.test.ts b/src/renderer/utils/forges/github/handlers/utils.test.ts index 9656fcdcc..9cc6095d3 100644 --- a/src/renderer/utils/forges/github/handlers/utils.test.ts +++ b/src/renderer/utils/forges/github/handlers/utils.test.ts @@ -2,8 +2,16 @@ import { mockAuthor } from '../__mocks__/response-mocks'; import { IconColor } from '../../../../types'; -import type { IssueTypeColor } from '../graphql/generated/graphql'; -import { getNotificationAuthor, mapIssueTypeColor } from './utils'; +import type { + IssueFieldSingleSelectOptionColor, + IssueTypeColor, +} from '../graphql/generated/graphql'; +import { + getNotificationAuthor, + mapGitHubColorToIconColor, + mapIssueFieldColor, + mapIssueTypeColor, +} from './utils'; describe('renderer/utils/notifications/handlers/utils.ts', () => { describe('getNotificationAuthor', () => { @@ -44,25 +52,31 @@ describe('renderer/utils/notifications/handlers/utils.ts', () => { }); }); - describe('mapIssueTypeColor', () => { + describe('mapGitHubColorToIconColor', () => { it.each([ ['RED', IconColor.RED], - ['GREEN', IconColor.GREEN], + ['ORANGE', IconColor.ORANGE], ['YELLOW', IconColor.YELLOW], - ['ORANGE', IconColor.YELLOW], - ['BLUE', IconColor.PURPLE], + ['GREEN', IconColor.GREEN], + ['BLUE', IconColor.BLUE], ['PURPLE', IconColor.PURPLE], - ['PINK', IconColor.PURPLE], + ['PINK', IconColor.PINK], ['GRAY', IconColor.GRAY], - ] satisfies [IssueTypeColor, IconColor][])( - 'maps %s to the expected token', + ] as const satisfies readonly (readonly [IssueTypeColor, IconColor])[])( + 'maps %s to the expected token via every entry point', (color, expected) => { + expect(mapGitHubColorToIconColor(color)).toBe(expected); expect(mapIssueTypeColor(color)).toBe(expected); + expect(mapIssueFieldColor(color as IssueFieldSingleSelectOptionColor)).toBe(expected); }, ); it('falls back to gray for a colour Gitify does not know about', () => { + expect(mapGitHubColorToIconColor('CHARTREUSE' as IssueTypeColor)).toBe(IconColor.GRAY); expect(mapIssueTypeColor('CHARTREUSE' as IssueTypeColor)).toBe(IconColor.GRAY); + expect(mapIssueFieldColor('CHARTREUSE' as IssueFieldSingleSelectOptionColor)).toBe( + IconColor.GRAY, + ); }); }); }); diff --git a/src/renderer/utils/forges/github/handlers/utils.ts b/src/renderer/utils/forges/github/handlers/utils.ts index 7b7beffad..88e621ed6 100644 --- a/src/renderer/utils/forges/github/handlers/utils.ts +++ b/src/renderer/utils/forges/github/handlers/utils.ts @@ -1,7 +1,11 @@ import type { GitifyNotificationUser, Link } from '../../../../types'; import { IconColor } from '../../../../types'; -import type { AuthorFieldsFragment, IssueTypeColor } from '../graphql/generated/graphql'; +import type { + AuthorFieldsFragment, + IssueFieldSingleSelectOptionColor, + IssueTypeColor, +} from '../graphql/generated/graphql'; // Author type from GraphQL or manually constructed type AuthorInput = AuthorFieldsFragment | GitifyNotificationUser | null | undefined; @@ -50,24 +54,42 @@ export function actionsURL(repositoryURL: string, filters: string[]): Link { return url.toString().replaceAll('%2B', '+') as Link; } +/** + * GitHub color enum shared between native issue types and issue field single-select options. + */ +export type GitHubColor = IssueTypeColor | IssueFieldSingleSelectOptionColor; + /** * Map GitHub's native issue type color to a Gitify icon color token. - * GitHub supports more colors than Gitify's fixed design token set, so - * this collapses to the closest available token. */ export function mapIssueTypeColor(color: IssueTypeColor): IconColor { + return mapGitHubColorToIconColor(color); +} + +/** + * Map a GitHub issue field option color to a Gitify icon color token. + */ +export function mapIssueFieldColor(color: IssueFieldSingleSelectOptionColor): IconColor { + return mapGitHubColorToIconColor(color); +} + +export function mapGitHubColorToIconColor(color: GitHubColor): IconColor { switch (color) { case 'RED': return IconColor.RED; - case 'GREEN': - return IconColor.GREEN; - case 'YELLOW': case 'ORANGE': + return IconColor.ORANGE; + case 'YELLOW': return IconColor.YELLOW; + case 'GREEN': + return IconColor.GREEN; case 'BLUE': + return IconColor.BLUE; case 'PURPLE': - case 'PINK': return IconColor.PURPLE; + case 'PINK': + return IconColor.PINK; + case 'GRAY': default: return IconColor.GRAY; } diff --git a/src/renderer/utils/forges/github/request.test.ts b/src/renderer/utils/forges/github/request.test.ts index 36e16334f..04b5c7ec6 100644 --- a/src/renderer/utils/forges/github/request.test.ts +++ b/src/renderer/utils/forges/github/request.test.ts @@ -5,6 +5,8 @@ import type { OctokitClient } from './octokit'; import * as octokitModule from './octokit'; import { performGraphQLRequest, performGraphQLRequestString } from './request'; +const GRAPHQL_FEATURES_HEADER = { 'GraphQL-Features': 'issue_fields' }; + // Manually mock Octokit for these tests vi.mock('@octokit/core', () => { const mockOctokit = { @@ -63,7 +65,7 @@ describe('renderer/utils/forges/github/request.ts', () => { expect(createOctokitClientSpy).toHaveBeenCalledWith(mockGitHubCloudAccount, 'graphql'); expect(mockOctokitInstance.graphql).toHaveBeenCalledWith( FetchIssueByNumberDocument.toString(), - { owner: 'test', name: 'repo', number: 1 }, + { owner: 'test', name: 'repo', number: 1, headers: GRAPHQL_FEATURES_HEADER }, ); }); @@ -74,6 +76,8 @@ describe('renderer/utils/forges/github/request.ts', () => { await performGraphQLRequestString(mockGitHubCloudAccount, queryString, {}); expect(createOctokitClientSpy).toHaveBeenCalledWith(mockGitHubCloudAccount, 'graphql'); - expect(mockOctokitInstance.graphql).toHaveBeenCalledWith(queryString, {}); + expect(mockOctokitInstance.graphql).toHaveBeenCalledWith(queryString, { + headers: GRAPHQL_FEATURES_HEADER, + }); }); }); diff --git a/src/renderer/utils/forges/github/request.ts b/src/renderer/utils/forges/github/request.ts index 536735ca8..5886d66f5 100644 --- a/src/renderer/utils/forges/github/request.ts +++ b/src/renderer/utils/forges/github/request.ts @@ -6,6 +6,13 @@ import { handleGraphQLResponseError } from '../../api/errors'; import type { TypedDocumentString } from './graphql/generated/graphql'; import { createOctokitClient } from './octokit'; +/** + * Request header that opts into GitHub's preview/feature-gated GraphQL schema + * additions. Without it the schema omits `issueFieldValues` (issue fields) and + * their union types. + */ +const GRAPHQL_FEATURES_HEADER = { 'GraphQL-Features': 'issue_fields' } as const; + /** * Perform a GraphQL API request with typed operation document. * @@ -22,7 +29,10 @@ export async function performGraphQLRequest( const octokit = await createOctokitClient(account, 'graphql'); try { - return await octokit.graphql(query.toString(), variables || {}); + return await octokit.graphql(query.toString(), { + ...variables, + headers: GRAPHQL_FEATURES_HEADER, + }); } catch (error) { if (error instanceof GraphqlResponseError) { handleGraphQLResponseError('performGraphQLRequest', error); @@ -50,7 +60,10 @@ export async function performGraphQLRequestString( const octokit = await createOctokitClient(account, 'graphql'); try { - return await octokit.graphql(query, variables || {}); + return await octokit.graphql(query, { + ...variables, + headers: GRAPHQL_FEATURES_HEADER, + }); } catch (error) { if (error instanceof GraphqlResponseError) { handleGraphQLResponseError('performGraphQLRequestString', error); diff --git a/tailwind.config.mts b/tailwind.config.mts index 8c5b712a9..69a8b721a 100644 --- a/tailwind.config.mts +++ b/tailwind.config.mts @@ -56,6 +56,9 @@ const config: Config = { done: 'var(--gitify-icon-done)', muted: 'var(--fgColor-muted)', open: 'var(--gitify-icon-open)', + severe: 'var(--gitify-icon-severe)', + accent: 'var(--gitify-icon-accent)', + sponsors: 'var(--gitify-icon-sponsors)', }, counter: {