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: {