diff --git a/codegen.ts b/codegen.ts index a2e7cd94e..5843c768f 100644 --- a/codegen.ts +++ b/codegen.ts @@ -20,6 +20,10 @@ const config: CodegenConfig = { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, }, + // GitHub's live schema currently fails graphql-js's stricter + // interface-deprecation-consistency validation (added in graphql v17). + // Skip validation so introspection can still succeed. + assumeValid: true, }, }, documents: ['src/renderer/utils/forges/github/**/*.graphql'], diff --git a/src/renderer/components/metrics/IssueTypesPill.test.tsx b/src/renderer/components/metrics/IssueTypesPill.test.tsx new file mode 100644 index 000000000..6819b1bee --- /dev/null +++ b/src/renderer/components/metrics/IssueTypesPill.test.tsx @@ -0,0 +1,19 @@ +import { screen } from '@testing-library/react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; + +import { IconColor } from '../../types'; + +import { IssueTypesPill } from './IssueTypesPill'; + +describe('renderer/components/metrics/IssueTypesPill.tsx', () => { + it('renders nothing when no type provided', () => { + const tree = renderWithProviders(); + expect(tree.container).toBeEmptyDOMElement(); + }); + + it('renders a pill for the native issue type', () => { + renderWithProviders(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/metrics/IssueTypesPill.tsx b/src/renderer/components/metrics/IssueTypesPill.tsx new file mode 100644 index 000000000..3cc3294d1 --- /dev/null +++ b/src/renderer/components/metrics/IssueTypesPill.tsx @@ -0,0 +1,19 @@ +import type { FC } from 'react'; + +import { IssueOpenedIcon } from '@primer/octicons-react'; + +import type { GitifyIssueType } from '../../types'; + +import { MetricPill } from './MetricPill'; + +export interface IssueTypesPillProps { + issueType?: GitifyIssueType; +} + +export const IssueTypesPill: FC = ({ issueType }) => { + if (!issueType) { + return null; + } + + return ; +}; diff --git a/src/renderer/components/metrics/MetricGroup.test.tsx b/src/renderer/components/metrics/MetricGroup.test.tsx index c7a041e96..e28e0585f 100644 --- a/src/renderer/components/metrics/MetricGroup.test.tsx +++ b/src/renderer/components/metrics/MetricGroup.test.tsx @@ -2,6 +2,8 @@ import { renderWithProviders } from '../../__helpers__/test-utils'; import { mockGitifyNotification } from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; +import { IconColor } from '../../types'; + import { MetricGroup, type MetricGroupProps } from './MetricGroup'; describe('renderer/components/metrics/MetricGroup.tsx', () => { @@ -30,4 +32,42 @@ describe('renderer/components/metrics/MetricGroup.tsx', () => { expect(tree.container).toMatchSnapshot(); }); + + it('should render the issue type pill when the subject has a native issue type', async () => { + const props: MetricGroupProps = { + notification: { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + issueType: { name: 'Bug', color: IconColor.RED }, + }, + }, + }; + + const tree = renderWithProviders(, { + settings: { ...mockSettings, showPills: true }, + }); + + expect(tree.getByText('Bug')).toBeInTheDocument(); + }); + + it('should render the stacked PR pill when the subject is part of a stack', async () => { + const props: MetricGroupProps = { + notification: { + ...mockGitifyNotification, + subject: { + ...mockGitifyNotification.subject, + isStacked: true, + stackPosition: 2, + stackDepth: 3, + }, + }, + }; + + const tree = renderWithProviders(, { + settings: { ...mockSettings, showPills: true }, + }); + + expect(tree.getByText('2/3')).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/metrics/MetricGroup.tsx b/src/renderer/components/metrics/MetricGroup.tsx index da0e7b6c5..30cef79e3 100644 --- a/src/renderer/components/metrics/MetricGroup.tsx +++ b/src/renderer/components/metrics/MetricGroup.tsx @@ -5,11 +5,13 @@ import { useSettingsStore } from '../../stores'; import type { GitifyNotification } from '../../types'; import { CommentsPill } from './CommentsPill'; +import { IssueTypesPill } from './IssueTypesPill'; import { LabelsPill } from './LabelsPill'; import { LinkedIssuesPill } from './LinkedIssuesPill'; import { MilestonePill } from './MilestonePill'; import { ReactionsPill } from './ReactionsPill'; import { ReviewsPill } from './ReviewsPill'; +import { StackedPrsPill } from './StackedPrsPill'; export interface MetricGroupProps { notification: GitifyNotification; @@ -24,8 +26,16 @@ export const MetricGroup: FC = ({ notification }) => { return (
+ + + + { + it('renders nothing when not stacked', () => { + const tree = renderWithProviders(); + expect(tree.container).toBeEmptyDOMElement(); + }); + + it('renders a pill when stacked', () => { + const tree = renderWithProviders(); + expect(tree.getByText('1/2')).toBeInTheDocument(); + }); + + it('renders a pill with no position/depth metric when position is missing', () => { + const tree = renderWithProviders(); + // Only the tooltip contents, with no metric text alongside it + expect(tree.container.textContent).toBe('Part of a stacked PR series'); + }); + + it('renders the metric for the first position in a stack', () => { + const tree = renderWithProviders(); + expect(tree.getByText('0/3')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/metrics/StackedPrsPill.tsx b/src/renderer/components/metrics/StackedPrsPill.tsx new file mode 100644 index 000000000..db3970c49 --- /dev/null +++ b/src/renderer/components/metrics/StackedPrsPill.tsx @@ -0,0 +1,35 @@ +import type { FC } from 'react'; + +import { GitMergeIcon } from '@primer/octicons-react'; + +import { IconColor } from '../../types'; + +import { MetricPill } from './MetricPill'; + +export interface StackedPrsPillProps { + isStacked?: boolean; + stackPosition?: number; + stackDepth?: number; +} + +export const StackedPrsPill: FC = ({ + isStacked, + stackPosition, + stackDepth, +}) => { + if (!isStacked) { + return null; + } + + const metric = + stackPosition != null && stackDepth != null ? `${stackPosition}/${stackDepth}` : undefined; + + return ( + + ); +}; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 85e61bbdb..753da4e39 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -394,6 +394,14 @@ export interface GitifySubject { commentCount?: number; /** Labels names and colors */ labels?: GitifyLabels[]; + /** Whether the PR is part of a GitHub native stacked PR series */ + isStacked?: boolean; + /** This PR's 1-indexed position in the stack, when part of a stacked PR series */ + stackPosition?: number; + /** Total number of PRs in the stack, when part of a stacked PR series */ + stackDepth?: number; + /** GitHub-native issue type (e.g. Bug, Feature, Task) */ + issueType?: GitifyIssueType; /** Milestone state/title */ milestone?: GitifyMilestone; /** Deep link to notification thread */ @@ -463,6 +471,12 @@ export interface GitifyNotificationDisplay { defaultUserType: UserType; } +/** GitHub-native issue type, normalized to a Gitify icon color token */ +export interface GitifyIssueType { + name: string; + 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 ec703a21e..b9dd5d2ea 100644 --- a/src/renderer/utils/forges/github/__mocks__/response-mocks.ts +++ b/src/renderer/utils/forges/github/__mocks__/response-mocks.ts @@ -100,6 +100,7 @@ export function mockIssueResponseNode(mocks: { labels: { nodes: [] }, comments: { totalCount: 0, nodes: [] }, milestone: null, + issueType: null, reactions: { totalCount: 0, }, @@ -142,6 +143,7 @@ export function mockPullRequestResponseNode(mocks: { closingIssuesReferences: { nodes: [], }, + stackEntry: null, reactions: { totalCount: 0, }, diff --git a/src/renderer/utils/forges/github/graphql/generated/graphql.ts b/src/renderer/utils/forges/github/graphql/generated/graphql.ts index 1c52ecf3f..2af087b47 100644 --- a/src/renderer/utils/forges/github/graphql/generated/graphql.ts +++ b/src/renderer/utils/forges/github/graphql/generated/graphql.ts @@ -33,6 +33,25 @@ export type IssueStateReason = /** An issue that has been reopened */ | 'REOPENED'; +/** The possible color for an issue type */ +export type IssueTypeColor = + /** blue */ + | 'BLUE' + /** gray */ + | 'GRAY' + /** green */ + | 'GREEN' + /** orange */ + | 'ORANGE' + /** pink */ + | 'PINK' + /** purple */ + | 'PURPLE' + /** red */ + | 'RED' + /** yellow */ + | 'YELLOW'; + /** The possible states of a milestone. */ export type MilestoneState = /** A milestone that has been closed. */ @@ -199,7 +218,7 @@ export type FetchIssueByNumberQuery = { repository: { issue: { __typename: 'Issu | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { 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, 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, 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' } @@ -213,7 +232,7 @@ export type IssueDetailsFragment = { __typename: 'Issue', number: number, title: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { 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, 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, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type FetchMergedDetailsTemplateQueryVariables = Exact<{ ownerINDEX: string; @@ -262,7 +281,7 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { 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, 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, 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' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -274,13 +293,19 @@ export type FetchMergedDetailsTemplateQuery = { repository: { discussion?: { __t | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: + | { __typename: 'Bot' } + | { __typename: 'EnterpriseTeam' } + | { __typename: 'Mannequin' } + | { __typename: 'Team' } + | { __typename: 'User', login: string } + | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { __typename: 'Discussion', number: number, title: string, stateReason: DiscussionStateReason | null, isAnswered?: boolean | null, url: Link, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -312,7 +337,7 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { 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, 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, 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' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'EnterpriseUserAccount' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } @@ -324,13 +349,19 @@ export type MergedDetailsQueryTemplateFragment = { repository: { discussion?: { | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: + | { __typename: 'Bot' } + | { __typename: 'EnterpriseTeam' } + | { __typename: 'Mannequin' } + | { __typename: 'Team' } + | { __typename: 'User', login: string } + | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type FetchPullRequestByNumberQueryVariables = Exact<{ owner: string; @@ -355,13 +386,19 @@ export type FetchPullRequestByNumberQuery = { repository: { pullRequest: { __typ | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: + | { __typename: 'Bot' } + | { __typename: 'EnterpriseTeam' } + | { __typename: 'Mannequin' } + | { __typename: 'Team' } + | { __typename: 'User', login: string } + | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null } | null }; export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: number, title: string, url: Link, state: PullRequestState, merged: boolean, isDraft: boolean, isInMergeQueue: boolean, milestone: { state: MilestoneState, title: string } | null, author: | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Bot' } @@ -375,13 +412,19 @@ export type PullRequestDetailsFragment = { __typename: 'PullRequest', number: nu | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Mannequin' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'Organization' } | { login: string, htmlUrl: Link, avatarUrl: Link, type: 'User' } - | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: { __typename: "User", login: string } | { __typename: "Team" } | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: + | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null } | null> | null }, reviewRequests: { nodes: Array<{ requestedReviewer: + | { __typename: 'Bot' } + | { __typename: 'EnterpriseTeam' } + | { __typename: 'Mannequin' } + | { __typename: 'Team' } + | { __typename: 'User', login: string } + | null } | null> | null } | null, reviews: { totalCount: number, nodes: Array<{ state: PullRequestReviewState, author: | { login: string } | { login: string } | { login: string } | { login: string } | { login: string } - | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; + | null } | null> | null } | null, labels: { nodes: Array<{ name: string, color: string } | null> | null } | null, closingIssuesReferences: { nodes: Array<{ number: number } | null> | null } | null, stackEntry: { position: number, stack: { size: number } | null } | null, reactions: { totalCount: number }, reactionGroups: Array<{ content: ReactionContent, reactors: { totalCount: number } }> | null }; export type PullRequestReviewFieldsFragment = { state: PullRequestReviewState, author: | { login: string } @@ -599,6 +642,10 @@ export const IssueDetailsFragmentDoc = new TypedDocumentString(` ...LabelFields } } + issueType { + name + color + } reactions { totalCount } @@ -694,6 +741,12 @@ export const PullRequestDetailsFragmentDoc = new TypedDocumentString(` number } } + stackEntry { + position + stack { + size + } + } reactions { totalCount } @@ -844,6 +897,10 @@ fragment IssueDetails on Issue { ...LabelFields } } + issueType { + name + color + } reactions { totalCount } @@ -910,6 +967,12 @@ fragment PullRequestDetails on PullRequest { number } } + stackEntry { + position + stack { + size + } + } reactions { totalCount } @@ -1058,6 +1121,10 @@ fragment IssueDetails on Issue { ...LabelFields } } + issueType { + name + color + } reactions { totalCount } @@ -1172,6 +1239,10 @@ fragment IssueDetails on Issue { ...LabelFields } } + issueType { + name + color + } reactions { totalCount } @@ -1251,6 +1322,12 @@ fragment PullRequestDetails on PullRequest { number } } + stackEntry { + position + stack { + size + } + } reactions { totalCount } @@ -1351,6 +1428,12 @@ fragment PullRequestDetails on PullRequest { number } } + stackEntry { + position + stack { + size + } + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/issue.graphql b/src/renderer/utils/forges/github/graphql/issue.graphql index 34743d0f3..745370cf9 100644 --- a/src/renderer/utils/forges/github/graphql/issue.graphql +++ b/src/renderer/utils/forges/github/graphql/issue.graphql @@ -47,6 +47,10 @@ fragment IssueDetails on Issue { ...LabelFields } } + issueType { + name + color + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/graphql/pull.graphql b/src/renderer/utils/forges/github/graphql/pull.graphql index ceca2ad82..efc5b543d 100644 --- a/src/renderer/utils/forges/github/graphql/pull.graphql +++ b/src/renderer/utils/forges/github/graphql/pull.graphql @@ -75,6 +75,12 @@ fragment PullRequestDetails on PullRequest { number } } + stackEntry { + position + stack { + size + } + } reactions { totalCount } diff --git a/src/renderer/utils/forges/github/handlers/issue.test.ts b/src/renderer/utils/forges/github/handlers/issue.test.ts index b39bc7c89..f106b12fa 100644 --- a/src/renderer/utils/forges/github/handlers/issue.test.ts +++ b/src/renderer/utils/forges/github/handlers/issue.test.ts @@ -254,6 +254,48 @@ describe('renderer/utils/notifications/handlers/issue.ts', () => { reactionGroups: noReactionGroups, } satisfies Partial); }); + + it('with native issue type', async () => { + const mockIssue = mockIssueResponseNode({ + state: 'OPEN', + }); + mockIssue.issueType = { + name: 'Bug', + color: 'RED', + }; + + fetchIssueByNumberSpy.mockResolvedValue({ + repository: { + issue: mockIssue, + }, + } satisfies FetchIssueByNumberQuery); + + const result = await issueHandler.enrich(mockNotification); + + expect(result).toEqual({ + number: 123, + state: 'OPEN', + user: { + login: mockAuthor.login, + avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, + type: mockAuthor.type, + }, + author: { + login: mockAuthor.login, + avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, + type: mockAuthor.type, + }, + commentCount: 0, + htmlUrl: 'https://github.com/gitify-app/notifications-test/issues/123' as Link, + labels: [], + issueType: { name: 'Bug', color: IconColor.RED }, + milestone: undefined, + reactionsCount: 0, + reactionGroups: noReactionGroups, + } satisfies Partial); + }); }); describe('iconType', () => { diff --git a/src/renderer/utils/forges/github/handlers/issue.ts b/src/renderer/utils/forges/github/handlers/issue.ts index db85ddc14..657d4b5ef 100644 --- a/src/renderer/utils/forges/github/handlers/issue.ts +++ b/src/renderer/utils/forges/github/handlers/issue.ts @@ -14,7 +14,7 @@ import { IconColor } from '../../../../types'; import { fetchIssueByNumber } from '../client'; import type { IssueDetailsFragment } from '../graphql/generated/graphql'; import { DefaultHandler, defaultHandler } from './default'; -import { getNotificationAuthor } from './utils'; +import { getNotificationAuthor, mapIssueTypeColor } from './utils'; class IssueHandler extends DefaultHandler { override readonly supportsMergedQueryEnrichment = true; @@ -52,6 +52,9 @@ class IssueHandler extends DefaultHandler { name: label!.name, color: label!.color, })) ?? [], + issueType: issue.issueType + ? { name: issue.issueType.name, color: mapIssueTypeColor(issue.issueType.color) } + : 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 d597c61fc..a94cbc3b8 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.test.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.test.ts @@ -214,6 +214,53 @@ describe('renderer/utils/notifications/handlers/pullRequest.ts', () => { } satisfies Partial); }); + it('pull request that is part of a native stacked PR series', async () => { + const mockPullRequest = mockPullRequestResponseNode({ state: 'OPEN' }); + mockPullRequest.stackEntry = { + position: 2, + stack: { + size: 3, + }, + }; + + fetchPullByNumberSpy.mockResolvedValue({ + repository: { + pullRequest: mockPullRequest, + }, + } satisfies FetchPullRequestByNumberQuery); + + const result = await pullRequestHandler.enrich(mockNotification); + + expect(result).toEqual({ + number: 123, + state: 'OPEN', + user: { + login: mockAuthor.login, + avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, + type: mockAuthor.type, + }, + author: { + login: mockAuthor.login, + avatarUrl: mockAuthor.avatarUrl, + htmlUrl: mockAuthor.htmlUrl, + type: mockAuthor.type, + }, + reviewRequested: [], + reviews: [], + labels: [], + isStacked: true, + stackPosition: 2, + stackDepth: 3, + linkedIssues: [], + commentCount: 0, + milestone: undefined, + htmlUrl: 'https://github.com/gitify-app/notifications-test/pulls/123' as Link, + reactionsCount: 0, + reactionGroups: noReactionGroups, + } satisfies Partial); + }); + it('with comments', async () => { const mockPullRequest = mockPullRequestResponseNode({ state: 'OPEN', diff --git a/src/renderer/utils/forges/github/handlers/pullRequest.ts b/src/renderer/utils/forges/github/handlers/pullRequest.ts index 1c3f3acf3..6f653b3a2 100644 --- a/src/renderer/utils/forges/github/handlers/pullRequest.ts +++ b/src/renderer/utils/forges/github/handlers/pullRequest.ts @@ -80,6 +80,9 @@ class PullRequestHandler extends DefaultHandler { name: label!.name, color: label!.color, })) ?? [], + isStacked: pr.stackEntry ? true : undefined, + stackPosition: pr.stackEntry?.position, + stackDepth: pr.stackEntry?.stack?.size, linkedIssues: pr.closingIssuesReferences?.nodes ?.filter(Boolean) .map((issue) => formatGitHubNumber(issue!.number)), diff --git a/src/renderer/utils/forges/github/handlers/utils.test.ts b/src/renderer/utils/forges/github/handlers/utils.test.ts index 007550ce1..6335ac9c1 100644 --- a/src/renderer/utils/forges/github/handlers/utils.test.ts +++ b/src/renderer/utils/forges/github/handlers/utils.test.ts @@ -1,6 +1,9 @@ import { mockAuthor } from '../__mocks__/response-mocks'; -import { getNotificationAuthor } from './utils'; +import { IconColor } from '../../../../types'; + +import type { IssueTypeColor } from '../graphql/generated/graphql'; +import { getNotificationAuthor, mapIssueTypeColor } from './utils'; describe('renderer/utils/notifications/handlers/utils.ts', () => { describe('getNotificationAuthor', () => { @@ -32,4 +35,26 @@ describe('renderer/utils/notifications/handlers/utils.ts', () => { }); }); }); + + describe('mapIssueTypeColor', () => { + it.each([ + ['RED', IconColor.RED], + ['GREEN', IconColor.GREEN], + ['YELLOW', IconColor.YELLOW], + ['ORANGE', IconColor.YELLOW], + ['BLUE', IconColor.PURPLE], + ['PURPLE', IconColor.PURPLE], + ['PINK', IconColor.PURPLE], + ['GRAY', IconColor.GRAY], + ] satisfies [IssueTypeColor, IconColor][])( + 'maps %s to the expected token', + (color, expected) => { + expect(mapIssueTypeColor(color)).toBe(expected); + }, + ); + + it('falls back to gray for a colour Gitify does not know about', () => { + expect(mapIssueTypeColor('CHARTREUSE' as IssueTypeColor)).toBe(IconColor.GRAY); + }); + }); }); diff --git a/src/renderer/utils/forges/github/handlers/utils.ts b/src/renderer/utils/forges/github/handlers/utils.ts index ab8c25f10..6493af183 100644 --- a/src/renderer/utils/forges/github/handlers/utils.ts +++ b/src/renderer/utils/forges/github/handlers/utils.ts @@ -1,6 +1,7 @@ import type { GitifyNotificationUser, Link } from '../../../../types'; +import { IconColor } from '../../../../types'; -import type { AuthorFieldsFragment } from '../graphql/generated/graphql'; +import type { AuthorFieldsFragment, IssueTypeColor } from '../graphql/generated/graphql'; // Author type from GraphQL or manually constructed type AuthorInput = AuthorFieldsFragment | GitifyNotificationUser | null | undefined; @@ -47,3 +48,26 @@ export function actionsURL(repositoryURL: string, filters: string[]): Link { // Note: the GitHub Actions UI cannot handle encoded '+' characters. return url.toString().replaceAll('%2B', '+') as Link; } + +/** + * 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 { + switch (color) { + case 'RED': + return IconColor.RED; + case 'GREEN': + return IconColor.GREEN; + case 'YELLOW': + case 'ORANGE': + return IconColor.YELLOW; + case 'BLUE': + case 'PURPLE': + case 'PINK': + return IconColor.PURPLE; + default: + return IconColor.GRAY; + } +}